From 1558c856975a11f17d995a3f9110efd980c11e79 Mon Sep 17 00:00:00 2001 From: Atakan Seckin Date: Thu, 6 Aug 2026 14:36:24 +0200 Subject: [PATCH 1/6] Midterms dashboard: race summary, seat medians, engagement block, insight cards Five changes to the /midterms-2026 hub: - Governor tab gets a top-line summary ("10 of 19 races lean Democrat"), rendered in a new absolutely-positioned header row shared with the chamber tabs and legend so a wrapping summary never pushes the map down. Lean split is >= 50%, matching the state tooltip so the two can't disagree. - Both seat-distribution charts gain a median callout: a dot on the x-axis at the median bin carrying an always-on VictoryTooltip. It hides while a bin is hovered so it never stacks with the hover tooltip, and is suppressed entirely when the median is exactly 0 (the EVEN bin already owns that spot). Hover flyouts go to 90% opacity, the median's to 80%, both +1px padding-top and ~2px more gap between lines. - Chamber control's pre-midterm baseline reads "Today:" instead of "Current:" and drops its party colors for gray, so it reads as context rather than competing with the live forecast above it. Also fixes an existing dark-mode bug: those two spans hardcoded light-mode hexes with no dark variant. - Community Insight cards carry their question title in a header strip, with the comment as the visual hero and the author as a footer. Height is capped rather than fixed, so short comments let the card shrink. - Adds the Labor Hub's three engagement cards (contact / share / newsletter) with midterms copy and its own Mailjet list. NewsletterSubscribeCard gained an optional `copy` prop defaulting to its current English, so the Labor Hub renders unchanged. Median-marker note: Victory's voronoi handleMouseMove calls onActivated(new) then onDeactivated(previous) back-to-back in the same tick, so an unconditional onDeactivated handler silently undoes every activation. Guarded with a ref. Co-Authored-By: Claude Opus 5 --- front_end/messages/cs.json | 20 +- front_end/messages/en.json | 22 +- front_end/messages/es.json | 20 +- front_end/messages/pt.json | 20 +- front_end/messages/zh-TW.json | 20 +- front_end/messages/zh.json | 20 +- front_end/src/app/(main)/actions.ts | 6 + .../components/newsletter_subscribe_card.tsx | 44 +++- .../components/chamber_control_card.tsx | 19 +- .../components/geographic_map.tsx | 23 +- .../midterms-2026/components/insight_card.tsx | 74 +++++-- .../components/race_lean_summary.tsx | 62 ++++++ .../components/responsive_map.tsx | 6 + .../components/seat_distribution_chart.tsx | 201 +++++++++++++++++- .../midterms-2026/helpers/post_utils.ts | 21 ++ .../src/app/(main)/midterms-2026/page.tsx | 2 + .../sections/engagement_section.tsx | 123 +++++++++++ 17 files changed, 649 insertions(+), 54 deletions(-) create mode 100644 front_end/src/app/(main)/midterms-2026/components/race_lean_summary.tsx create mode 100644 front_end/src/app/(main)/midterms-2026/sections/engagement_section.tsx diff --git a/front_end/messages/cs.json b/front_end/messages/cs.json index b5bd213d8b..fac1cd6ce5 100644 --- a/front_end/messages/cs.json +++ b/front_end/messages/cs.json @@ -2253,7 +2253,7 @@ "thousandsOfOpenQuestions": "20 000+ otevřených otázek", "midtermsHubMetaTitle": "Mezičasové volby USA 2026 | Metaculus", "midtermsHubMetaDescription": "Předpovědi komunity Metaculus v reálném čase k mezičasovým volbám USA v roce 2026.", - "midtermsHubChamberCurrent": "Aktuálně:", + "midtermsHubChamberToday": "Dnes:", "midtermsHubChamberForecast": "Předpověď:", "midtermsHubChamberTooltipBody": "Prognostici dávají {party} {pct}% šanci na získání nejvíce křesel v {chamber}.", "midtermsHubChamberTooltipDisclaimer": "Klikněte pro zobrazení předpovědní otázky", @@ -2296,6 +2296,7 @@ "midtermsHubScrollRight": "Posunout poznatky doprava", "midtermsHubDemocrat": "Demokraté", "midtermsHubRepublican": "Republikáni", + "midtermsHubRaceLeanSummary": "V {count} z {total} soubojů vedou {party}", "midtermsHubNotContested": "Bez voleb", "midtermsHubDemPct": "{pct}% demokraté", "midtermsHubRepPct": "{pct}% republikáni", @@ -2317,11 +2318,28 @@ "midtermsHubDemSeatAdvantage": "Demokratická převaha mandátů", "midtermsHubRepSeatAdvantage": "Republikánská převaha mandátů", "midtermsHubEven": "REMÍZA", + "midtermsHubMedianLabel": "Medián:", + "midtermsHubMedianDem": "D +{count, plural, one {# mandát} few {# mandáty} other {# mandátů}}", + "midtermsHubMedianRep": "R +{count, plural, one {# mandát} few {# mandáty} other {# mandátů}}", "midtermsHubForecastUnavailable": "Předpověď nedostupná", "midtermsHubSeatAdvantageTooltip": "Náskok {count} mandátů", "midtermsHubSeatAdvantageOverTooltip": "Náskok více než {count} mandátů", "midtermsHubProbabilityTooltip": "{value}% pravděpodobnost", "midtermsHubEvenTooltip": "Remíza", + "midtermsHubEngagementReachTitle": "Kontaktujte Metaculus", + "midtermsHubEngagementReachBody": "Máte postřehy, dotazy nebo jste našli něco nejasného? Vaše zpětná vazba nám pomáhá tento přehled zlepšovat.", + "midtermsHubEngagementViewServices": "Zobrazit služby", + "midtermsHubEngagementShareTitle": "Sdílejte přehled", + "midtermsHubEngagementShareBody": "Myslíte, že to pomůže i ostatním sledovat americké volby v polovině mandátu 2026? Sdílejte to se svou sítí.", + "midtermsHubEngagementShareOnX": "Sdílet na X", + "midtermsHubEngagementTweetText": "Podívejte se na přehled predikcí amerických voleb v polovině mandátu 2026 na Metaculu", + "midtermsHubEngagementNewsletterTitle": "Přihlaste se k odběru novinek", + "midtermsHubEngagementNewsletterBody": "Zaregistrujte se a dostanete upozornění, když se předpovědi změní a zveřejníme nové volební analýzy.", + "midtermsHubEngagementNewsletterSubmit": "Uložit", + "midtermsHubEngagementNewsletterSuccess": "Odběr je aktivní!", + "midtermsHubEngagementEmailPlaceholder": "E-mailová adresa", + "midtermsHubEngagementToastSuccess": "Odběr byl úspěšně zahájen!", + "midtermsHubEngagementToastError": "Odběr se nepodařilo nastavit. Zkuste to prosím znovu.", "nMore": "{count} více", "noHistogramData": "Žádná data histogramu nejsou k dispozici", "laborHubJobsBreadcrumb": "Všechna povolání", diff --git a/front_end/messages/en.json b/front_end/messages/en.json index a14dc09d11..22e97f9e53 100644 --- a/front_end/messages/en.json +++ b/front_end/messages/en.json @@ -2327,7 +2327,7 @@ "midtermsHubChamberGovernor": "Governor", "midtermsHubChamberControl": "Chamber control", "midtermsHubCongressForecast": "Congressional Control", - "midtermsHubChamberCurrent": "Current:", + "midtermsHubChamberToday": "Today:", "midtermsHubChamberForecast": "Forecast:", "midtermsHubChamberTooltipBody": "Forecasters give {party} a {pct}% chance of holding the most {chamber} seats.", "midtermsHubChamberTooltipDisclaimer": "Click to view forecasting question", @@ -2365,6 +2365,7 @@ "midtermsHubScrollRight": "Scroll insights right", "midtermsHubDemocrat": "Democrat", "midtermsHubRepublican": "Republican", + "midtermsHubRaceLeanSummary": "{count} of {total} races lean {party}", "midtermsHubNotContested": "Not contested", "midtermsHubDemPct": "{pct}% Democrat", "midtermsHubRepPct": "{pct}% Republican", @@ -2386,9 +2387,26 @@ "midtermsHubDemSeatAdvantage": "Democratic Seat Advantage", "midtermsHubRepSeatAdvantage": "Republican Seat Advantage", "midtermsHubEven": "EVEN", + "midtermsHubMedianLabel": "Median:", + "midtermsHubMedianDem": "D +{count, plural, one {# seat} other {# seats}}", + "midtermsHubMedianRep": "R +{count, plural, one {# seat} other {# seats}}", "midtermsHubForecastUnavailable": "Forecast unavailable", "midtermsHubSeatAdvantageTooltip": "{count} seat advantage", "midtermsHubSeatAdvantageOverTooltip": ">{count} seat advantage", "midtermsHubProbabilityTooltip": "{value}% probability", - "midtermsHubEvenTooltip": "Even" + "midtermsHubEvenTooltip": "Even", + "midtermsHubEngagementReachTitle": "Reach out to Metaculus", + "midtermsHubEngagementReachBody": "Have thoughts, questions, or spotted something unclear? Your input helps us improve this dashboard.", + "midtermsHubEngagementViewServices": "View Services", + "midtermsHubEngagementShareTitle": "Share the dashboard", + "midtermsHubEngagementShareBody": "Think this could help others follow the 2026 midterms? Share it with your network.", + "midtermsHubEngagementShareOnX": "Share on X", + "midtermsHubEngagementTweetText": "Check out the 2026 US Midterm Elections forecasting dashboard on Metaculus", + "midtermsHubEngagementNewsletterTitle": "Subscribe for updates", + "midtermsHubEngagementNewsletterBody": "Sign up to get notified as forecasts shift and we publish new midterms insights.", + "midtermsHubEngagementNewsletterSubmit": "Save", + "midtermsHubEngagementNewsletterSuccess": "You're subscribed!", + "midtermsHubEngagementEmailPlaceholder": "Email address", + "midtermsHubEngagementToastSuccess": "Subscribed successfully!", + "midtermsHubEngagementToastError": "Failed to subscribe. Please try again." } diff --git a/front_end/messages/es.json b/front_end/messages/es.json index a3d1f7276f..5ca7499495 100644 --- a/front_end/messages/es.json +++ b/front_end/messages/es.json @@ -2253,7 +2253,7 @@ "thousandsOfOpenQuestions": "20,000+ preguntas abiertas", "midtermsHubMetaTitle": "Elecciones intermedias de EE. UU. de 2026 | Metaculus", "midtermsHubMetaDescription": "Pronósticos en tiempo real de la comunidad de Metaculus sobre las elecciones intermedias de EE. UU. de 2026.", - "midtermsHubChamberCurrent": "Actual:", + "midtermsHubChamberToday": "Hoy:", "midtermsHubChamberForecast": "Pronóstico:", "midtermsHubChamberTooltipBody": "Los pronosticadores dan a {party} un {pct}% de probabilidad de obtener la mayor cantidad de escaños en {chamber}.", "midtermsHubChamberTooltipDisclaimer": "Haz clic para ver la pregunta de pronóstico", @@ -2296,6 +2296,7 @@ "midtermsHubScrollRight": "Desplazar perspectivas a la derecha", "midtermsHubDemocrat": "Demócrata", "midtermsHubRepublican": "Republicano", + "midtermsHubRaceLeanSummary": "{count} de {total} contiendas favorecen al partido {party}", "midtermsHubNotContested": "No disputado", "midtermsHubDemPct": "{pct}% Demócrata", "midtermsHubRepPct": "{pct}% Republicano", @@ -2317,11 +2318,28 @@ "midtermsHubDemSeatAdvantage": "Ventaja de escaños demócratas", "midtermsHubRepSeatAdvantage": "Ventaja de escaños republicanos", "midtermsHubEven": "EMPATE", + "midtermsHubMedianLabel": "Mediana:", + "midtermsHubMedianDem": "D +{count, plural, one {# escaño} other {# escaños}}", + "midtermsHubMedianRep": "R +{count, plural, one {# escaño} other {# escaños}}", "midtermsHubForecastUnavailable": "Pronóstico no disponible", "midtermsHubSeatAdvantageTooltip": "{count} escaños de ventaja", "midtermsHubSeatAdvantageOverTooltip": ">{count} escaños de ventaja", "midtermsHubProbabilityTooltip": "{value}% de probabilidad", "midtermsHubEvenTooltip": "Empate", + "midtermsHubEngagementReachTitle": "Ponte en contacto con Metaculus", + "midtermsHubEngagementReachBody": "¿Tienes ideas, preguntas o has detectado algo confuso? Tus comentarios nos ayudan a mejorar este panel.", + "midtermsHubEngagementViewServices": "Ver servicios", + "midtermsHubEngagementShareTitle": "Comparte el panel", + "midtermsHubEngagementShareBody": "¿Crees que esto puede ayudar a otras personas a seguir las elecciones de medio mandato de 2026? Compártelo con tu red.", + "midtermsHubEngagementShareOnX": "Compartir en X", + "midtermsHubEngagementTweetText": "Echa un vistazo al panel de previsiones de las elecciones de medio mandato de EE. UU. de 2026 en Metaculus", + "midtermsHubEngagementNewsletterTitle": "Suscríbete para recibir novedades", + "midtermsHubEngagementNewsletterBody": "Regístrate para recibir avisos cuando cambien las previsiones y publiquemos nuevos análisis electorales.", + "midtermsHubEngagementNewsletterSubmit": "Guardar", + "midtermsHubEngagementNewsletterSuccess": "¡Ya estás suscrito!", + "midtermsHubEngagementEmailPlaceholder": "Correo electrónico", + "midtermsHubEngagementToastSuccess": "¡Suscripción realizada!", + "midtermsHubEngagementToastError": "No se pudo completar la suscripción. Inténtalo de nuevo.", "nMore": "{count} más", "noHistogramData": "No hay datos de histograma disponibles", "laborHubJobsBreadcrumb": "Todos los empleos", diff --git a/front_end/messages/pt.json b/front_end/messages/pt.json index da0b452e04..6052e99c9b 100644 --- a/front_end/messages/pt.json +++ b/front_end/messages/pt.json @@ -2251,7 +2251,7 @@ "thousandsOfOpenQuestions": "20.000+ perguntas abertas", "midtermsHubMetaTitle": "Eleições de meio de mandato dos EUA 2026 | Metaculus", "midtermsHubMetaDescription": "Previsões em tempo real da comunidade Metaculus sobre as eleições de meio de mandato dos EUA de 2026.", - "midtermsHubChamberCurrent": "Atual:", + "midtermsHubChamberToday": "Hoje:", "midtermsHubChamberForecast": "Previsão:", "midtermsHubChamberTooltipBody": "Os analistas dão a {party} uma probabilidade de {pct}% de obter o maior número de cadeiras na {chamber}.", "midtermsHubChamberTooltipDisclaimer": "Clique para ver a pergunta de previsão", @@ -2294,6 +2294,7 @@ "midtermsHubScrollRight": "Rolar insights para a direita", "midtermsHubDemocrat": "Democrata", "midtermsHubRepublican": "Republicano", + "midtermsHubRaceLeanSummary": "{count} de {total} disputas favorecem o partido {party}", "midtermsHubNotContested": "Sem disputa", "midtermsHubDemPct": "{pct}% Democrata", "midtermsHubRepPct": "{pct}% Republicano", @@ -2315,11 +2316,28 @@ "midtermsHubDemSeatAdvantage": "Vantagem de cadeiras democratas", "midtermsHubRepSeatAdvantage": "Vantagem de cadeiras republicanas", "midtermsHubEven": "EMPATE", + "midtermsHubMedianLabel": "Mediana:", + "midtermsHubMedianDem": "D +{count, plural, one {# cadeira} other {# cadeiras}}", + "midtermsHubMedianRep": "R +{count, plural, one {# cadeira} other {# cadeiras}}", "midtermsHubForecastUnavailable": "Previsão indisponível", "midtermsHubSeatAdvantageTooltip": "{count} cadeiras de vantagem", "midtermsHubSeatAdvantageOverTooltip": ">{count} cadeiras de vantagem", "midtermsHubProbabilityTooltip": "{value}% de probabilidade", "midtermsHubEvenTooltip": "Empate", + "midtermsHubEngagementReachTitle": "Fale com a Metaculus", + "midtermsHubEngagementReachBody": "Tem ideias, dúvidas ou encontrou algo pouco claro? Sua opinião nos ajuda a melhorar este painel.", + "midtermsHubEngagementViewServices": "Ver serviços", + "midtermsHubEngagementShareTitle": "Compartilhe o painel", + "midtermsHubEngagementShareBody": "Acha que isso pode ajudar outras pessoas a acompanhar as eleições de meio de mandato de 2026? Compartilhe com sua rede.", + "midtermsHubEngagementShareOnX": "Compartilhar no X", + "midtermsHubEngagementTweetText": "Confira o painel de previsões das eleições de meio de mandato dos EUA de 2026 na Metaculus", + "midtermsHubEngagementNewsletterTitle": "Assine para receber novidades", + "midtermsHubEngagementNewsletterBody": "Cadastre-se para ser avisado quando as previsões mudarem e publicarmos novas análises eleitorais.", + "midtermsHubEngagementNewsletterSubmit": "Salvar", + "midtermsHubEngagementNewsletterSuccess": "Você está inscrito!", + "midtermsHubEngagementEmailPlaceholder": "Endereço de e-mail", + "midtermsHubEngagementToastSuccess": "Inscrição realizada com sucesso!", + "midtermsHubEngagementToastError": "Não foi possível concluir a inscrição. Tente novamente.", "nMore": "{count} a mais", "noHistogramData": "Não há dados de histograma disponíveis", "laborHubJobsBreadcrumb": "Todas as profissões", diff --git a/front_end/messages/zh-TW.json b/front_end/messages/zh-TW.json index 11f18ee7bd..c55a60100d 100644 --- a/front_end/messages/zh-TW.json +++ b/front_end/messages/zh-TW.json @@ -2250,7 +2250,7 @@ "thousandsOfOpenQuestions": "20,000+ 開放問題", "midtermsHubMetaTitle": "2026 年美國期中選舉 | Metaculus", "midtermsHubMetaDescription": "Metaculus 社群對 2026 年美國期中選舉的即時預測。", - "midtermsHubChamberCurrent": "目前:", + "midtermsHubChamberToday": "今日:", "midtermsHubChamberForecast": "預測:", "midtermsHubChamberTooltipBody": "預測者認為{party}有 {pct}% 的機率在{chamber}中贏得最多席位。", "midtermsHubChamberTooltipDisclaimer": "點擊查看預測問題", @@ -2293,6 +2293,7 @@ "midtermsHubScrollRight": "向右捲動觀點", "midtermsHubDemocrat": "民主黨", "midtermsHubRepublican": "共和黨", + "midtermsHubRaceLeanSummary": "在 {total} 場競選中,{count} 場傾向{party}", "midtermsHubNotContested": "無競爭", "midtermsHubDemPct": "{pct}% 民主黨", "midtermsHubRepPct": "{pct}% 共和黨", @@ -2314,11 +2315,28 @@ "midtermsHubDemSeatAdvantage": "民主黨席位優勢", "midtermsHubRepSeatAdvantage": "共和黨席位優勢", "midtermsHubEven": "持平", + "midtermsHubMedianLabel": "中位數:", + "midtermsHubMedianDem": "民主黨 +{count} 席", + "midtermsHubMedianRep": "共和黨 +{count} 席", "midtermsHubForecastUnavailable": "暫無預測", "midtermsHubSeatAdvantageTooltip": "領先 {count} 席", "midtermsHubSeatAdvantageOverTooltip": "領先超過 {count} 席", "midtermsHubProbabilityTooltip": "{value}% 機率", "midtermsHubEvenTooltip": "持平", + "midtermsHubEngagementReachTitle": "聯絡 Metaculus", + "midtermsHubEngagementReachBody": "有想法、疑問,或發現了不清楚的地方?您的回饋能幫助我們改進這個面板。", + "midtermsHubEngagementViewServices": "查看服務", + "midtermsHubEngagementShareTitle": "分享這個面板", + "midtermsHubEngagementShareBody": "覺得這能幫助其他人了解 2026 年美國期中選舉嗎?分享給你的朋友。", + "midtermsHubEngagementShareOnX": "在 X 上分享", + "midtermsHubEngagementTweetText": "來看看 Metaculus 上的 2026 年美國期中選舉預測面板", + "midtermsHubEngagementNewsletterTitle": "訂閱更新", + "midtermsHubEngagementNewsletterBody": "註冊後,我們會在預測發生變化或發布新的期中選舉分析時通知您。", + "midtermsHubEngagementNewsletterSubmit": "儲存", + "midtermsHubEngagementNewsletterSuccess": "您已訂閱!", + "midtermsHubEngagementEmailPlaceholder": "電子郵件地址", + "midtermsHubEngagementToastSuccess": "訂閱成功!", + "midtermsHubEngagementToastError": "訂閱失敗,請重試。", "nMore": "還有 {count} 個", "noHistogramData": "沒有可用的直方圖資料", "laborHubJobsBreadcrumb": "所有職業", diff --git a/front_end/messages/zh.json b/front_end/messages/zh.json index d02e635d54..d42e0139f5 100644 --- a/front_end/messages/zh.json +++ b/front_end/messages/zh.json @@ -2254,7 +2254,7 @@ "thousandsOfOpenQuestions": "20,000+ 开放问题", "midtermsHubMetaTitle": "2026 年美国中期选举 | Metaculus", "midtermsHubMetaDescription": "Metaculus 社区对 2026 年美国中期选举的实时预测。", - "midtermsHubChamberCurrent": "当前:", + "midtermsHubChamberToday": "今天:", "midtermsHubChamberForecast": "预测:", "midtermsHubChamberTooltipBody": "预测者认为{party}有 {pct}% 的概率在{chamber}中赢得最多席位。", "midtermsHubChamberTooltipDisclaimer": "点击查看预测问题", @@ -2297,6 +2297,7 @@ "midtermsHubScrollRight": "向右滚动洞察", "midtermsHubDemocrat": "民主党", "midtermsHubRepublican": "共和党", + "midtermsHubRaceLeanSummary": "在 {total} 场竞选中,{count} 场倾向{party}", "midtermsHubNotContested": "无竞争", "midtermsHubDemPct": "{pct}% 民主党", "midtermsHubRepPct": "{pct}% 共和党", @@ -2318,11 +2319,28 @@ "midtermsHubDemSeatAdvantage": "民主党席位优势", "midtermsHubRepSeatAdvantage": "共和党席位优势", "midtermsHubEven": "持平", + "midtermsHubMedianLabel": "中位数:", + "midtermsHubMedianDem": "民主党 +{count} 席", + "midtermsHubMedianRep": "共和党 +{count} 席", "midtermsHubForecastUnavailable": "暂无预测", "midtermsHubSeatAdvantageTooltip": "领先 {count} 席", "midtermsHubSeatAdvantageOverTooltip": "领先超过 {count} 席", "midtermsHubProbabilityTooltip": "{value}% 概率", "midtermsHubEvenTooltip": "持平", + "midtermsHubEngagementReachTitle": "联系 Metaculus", + "midtermsHubEngagementReachBody": "有想法、疑问,或发现了不清楚的地方?您的反馈能帮助我们改进这个面板。", + "midtermsHubEngagementViewServices": "查看服务", + "midtermsHubEngagementShareTitle": "分享这个面板", + "midtermsHubEngagementShareBody": "觉得这能帮助其他人了解 2026 年美国中期选举吗?分享给你的朋友。", + "midtermsHubEngagementShareOnX": "在 X 上分享", + "midtermsHubEngagementTweetText": "来看看 Metaculus 上的 2026 年美国中期选举预测面板", + "midtermsHubEngagementNewsletterTitle": "订阅更新", + "midtermsHubEngagementNewsletterBody": "注册后,我们会在预测发生变化或发布新的中期选举分析时通知您。", + "midtermsHubEngagementNewsletterSubmit": "保存", + "midtermsHubEngagementNewsletterSuccess": "您已订阅!", + "midtermsHubEngagementEmailPlaceholder": "电子邮件地址", + "midtermsHubEngagementToastSuccess": "订阅成功!", + "midtermsHubEngagementToastError": "订阅失败,请重试。", "nMore": "{count} 更多", "noHistogramData": "没有可用的直方图数据", "laborHubJobsBreadcrumb": "所有职业", diff --git a/front_end/src/app/(main)/actions.ts b/front_end/src/app/(main)/actions.ts index 4fdd40edf4..9bcc729f98 100644 --- a/front_end/src/app/(main)/actions.ts +++ b/front_end/src/app/(main)/actions.ts @@ -18,8 +18,14 @@ export async function dismissFeedTile(id: string) { return await serverMiscApi.dismissFeedTile(id); } +// Mailjet contact list per hub. A listKey that isn't in here resolves to +// undefined and then falls back to MAILJET_NEWSLETTER_ID downstream (see +// misc.server.ts), so a missing or misspelled entry silently subscribes people to +// the general newsletter instead of erroring — always add the key here alongside +// the hub that passes it. const NEWSLETTER_LIST_IDS: Record = { labor: "10617959", + midterms: "10628608", }; export async function subscribeToNewsletter(email: string, listKey?: string) { diff --git a/front_end/src/app/(main)/labor-hub/components/newsletter_subscribe_card.tsx b/front_end/src/app/(main)/labor-hub/components/newsletter_subscribe_card.tsx index 6d188ba9ce..2b2fa02913 100644 --- a/front_end/src/app/(main)/labor-hub/components/newsletter_subscribe_card.tsx +++ b/front_end/src/app/(main)/labor-hub/components/newsletter_subscribe_card.tsx @@ -10,21 +10,48 @@ import cn from "@/utils/core/cn"; import { subscribeToNewsletter } from "../../actions"; +export type NewsletterCopy = { + title: string; + description: string; + placeholder: string; + submit: string; + success: string; + toastSuccess: string; + toastError: string; +}; + +// The Labor Hub's original hardcoded English. Callers that are translated (e.g. +// the Midterms hub) pass localized strings via `copy`; omitting it keeps this +// card rendering exactly as it always has. +const DEFAULT_COPY: NewsletterCopy = { + title: "Subscribe for updates", + description: + "Sign up to get notified when we publish new forecasts, insights, and improvements.", + placeholder: "Email address", + submit: "Save", + success: "You're subscribed!", + toastSuccess: "Subscribed successfully!", + toastError: "Failed to subscribe. Please try again.", +}; + type Props = { listKey?: string; className?: string; formClassName?: string; + copy?: Partial; }; export function NewsletterSubscribeCard({ listKey, className, formClassName, + copy, }: Props) { const { user } = useAuth(); const [email, setEmail] = useState(user?.email ?? ""); const [isLoading, setIsLoading] = useState(false); const [isSuccess, setIsSuccess] = useState(false); + const text = { ...DEFAULT_COPY, ...copy }; const handleSubmit = async (e: FormEvent) => { e.preventDefault(); @@ -35,11 +62,11 @@ export function NewsletterSubscribeCard({ await subscribeToNewsletter(email, listKey); setIsSuccess(true); setEmail(""); - toast("Subscribed successfully!", { + toast(text.toastSuccess, { className: "dark:bg-blue-700-dark dark:text-gray-0-dark", }); } catch { - toast.error("Failed to subscribe. Please try again."); + toast.error(text.toastError); } finally { setIsLoading(false); } @@ -59,17 +86,16 @@ export function NewsletterSubscribeCard({ className="text-xl text-purple-800 dark:text-purple-800-dark lg:text-[22px]" />

- Subscribe for updates + {text.title}

- Sign up to get notified when we publish new forecasts, insights, and - improvements. + {text.description}

{isSuccess ? (
- You're subscribed! + {text.success}
) : (
setEmail(e.target.value)} - aria-label="Email address" - placeholder="Email address" + aria-label={text.placeholder} + placeholder={text.placeholder} className="min-w-0 flex-1 rounded bg-gray-0 px-3 py-2 text-sm text-purple-900 placeholder:text-purple-600 focus:outline-none dark:bg-gray-0-dark dark:text-purple-900-dark dark:placeholder:text-purple-600-dark" required disabled={isLoading} @@ -94,7 +120,7 @@ export function NewsletterSubscribeCard({ disabled={isLoading} className="rounded bg-blue-800 px-3 py-2 text-sm text-gray-0 transition-colors hover:bg-blue-900 disabled:opacity-50 dark:bg-blue-800-dark dark:text-gray-0-dark dark:hover:bg-blue-900-dark" > - Save + {text.submit}
)} diff --git a/front_end/src/app/(main)/midterms-2026/components/chamber_control_card.tsx b/front_end/src/app/(main)/midterms-2026/components/chamber_control_card.tsx index 097da0b2fd..513bcb0fc5 100644 --- a/front_end/src/app/(main)/midterms-2026/components/chamber_control_card.tsx +++ b/front_end/src/app/(main)/midterms-2026/components/chamber_control_card.tsx @@ -55,7 +55,7 @@ export default async function ChamberControlCard({ data }: Props) { const labels = { forecast: t("midtermsHubChamberForecast"), - current: t("midtermsHubChamberCurrent"), + today: t("midtermsHubChamberToday"), democrats: t("midtermsHubPartyDemocrats"), republicans: t("midtermsHubPartyRepublicans"), }; @@ -126,7 +126,7 @@ export default async function ChamberControlCard({ data }: Props) { type Labels = { forecast: string; - current: string; + today: string; democrats: string; republicans: string; }; @@ -222,15 +222,14 @@ function ChamberRow({ )} -
- {labels.current} - - D {currentDem} - + {/* Pre-midterm baseline. Deliberately gray rather than party-colored (or + even the card's blue) so it reads as context and doesn't compete with + the live forecast above it. */} +
+ {labels.today} + D {currentDem} {slash} - - R {currentRep} - + R {currentRep}
); diff --git a/front_end/src/app/(main)/midterms-2026/components/geographic_map.tsx b/front_end/src/app/(main)/midterms-2026/components/geographic_map.tsx index b7cde07bf8..dc98f79e8f 100644 --- a/front_end/src/app/(main)/midterms-2026/components/geographic_map.tsx +++ b/front_end/src/app/(main)/midterms-2026/components/geographic_map.tsx @@ -88,8 +88,10 @@ const FIPS_TO_ABBR: Record = { type Props = { races: SenateRaceWithQuestion[]; - /** Tabs slot (rendered absolute top-left). */ + /** Tabs slot (rendered top-left of the header overlay). */ tabsSlot?: ReactNode; + /** Optional summary line, centered between the tabs and the legend. */ + summarySlot?: ReactNode; }; type HoverState = { @@ -111,7 +113,7 @@ const MAP_TRANSLATE: [number, number] = [400, 270]; const UNCONTESTED_OPACITY_DEFAULT = 0.75; const UNCONTESTED_OPACITY_HOVER = 1; -const GeographicMap: FC = ({ races, tabsSlot }) => { +const GeographicMap: FC = ({ races, tabsSlot, summarySlot }) => { const isDark = useIsDark(); const strokeColor = isDark @@ -232,13 +234,18 @@ const GeographicMap: FC = ({ races, tabsSlot }) => { ref={containerRef} className="geo-map-container relative h-full w-full" > - {tabsSlot && ( -
- {tabsSlot} + {/* Header overlay: tabs | summary | legend. Absolutely positioned as one + row so a wrapping summary grows over the map instead of pushing it + down, while the gap keeps it clear of both neighbors. The paddings + reproduce the standalone positions these two used to have. */} +
+ {tabsSlot &&
{tabsSlot}
} +
+ {summarySlot} +
+
+
- )} -
-
= ({ insight }) => { const t = useTranslations(); const { comment, sourcePost } = insight; + // Comments are fetched per post, so for the Senate / Governor group posts this + // is the umbrella question title rather than the individual state race. + const questionTitle = sourcePost.short_title || sourcePost.title; return ( -
- -

{extractCommentText(comment)}

-
-
+ + {/* Context strip: which question the comment is about. */} +
+ + {/* Wraps to a second line only when the title needs it — short titles + keep the strip to one line. */} + + {questionTitle} + +
+ + {/* The comment is the point of the card — largest type, all the room. + py-3 rather than py-3.5: six lines of 22.75px plus 24px of padding is + what still clears the 270px cap when the title above wraps to two + lines (54px strip + 53px footer), so the last line never gets shaved. */} +
+

+ {extractCommentText(comment)} +

+
+ +
+
+
+ {comment.author.username} +
+
+ {t("midtermsHubMetaculusUser")} +
+
+ +
+ ); }; function extractCommentText(comment: CommentType): string { - return stripMarkdown(comment.text).slice(0, 320); + return stripMarkdown(comment.text).slice(0, EXCERPT_CHARS); } export default InsightCard; diff --git a/front_end/src/app/(main)/midterms-2026/components/race_lean_summary.tsx b/front_end/src/app/(main)/midterms-2026/components/race_lean_summary.tsx new file mode 100644 index 0000000000..b11302f4d4 --- /dev/null +++ b/front_end/src/app/(main)/midterms-2026/components/race_lean_summary.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { FC } from "react"; + +import cn from "@/utils/core/cn"; + +import { MIDTERMS_COLORS } from "../constants"; +import { + SenateRaceWithQuestion, + summarizeRaceLeans, +} from "../helpers/post_utils"; +import { useIsDark } from "../helpers/use_is_dark"; + +type Props = { + races: SenateRaceWithQuestion[]; + className?: string; +}; + +const RaceLeanSummary: FC = ({ races, className }) => { + const t = useTranslations(); + const isDark = useIsDark(); + const leans = summarizeRaceLeans(races); + if (!leans) return null; + + // Name the side ahead in more races; the remainder is implied. A tie resolves + // to the Democrats so the copy stays deterministic across renders. + const demLeads = leans.dem >= leans.rep; + + // Same light/dark pairing the chart strokes and CvBar use: the darker border + // tone reads on the white card, the brighter tone on the navy one. Follows the + // leading party, so this flips to red on its own if forecasts move. + const partyColor = demLeads + ? isDark + ? MIDTERMS_COLORS.demPrimaryDark + : MIDTERMS_COLORS.demBorder + : isDark + ? MIDTERMS_COLORS.repPrimaryDark + : MIDTERMS_COLORS.repBorder; + + return ( +

+ {t.rich("midtermsHubRaceLeanSummary", { + count: demLeads ? leans.dem : leans.rep, + total: leans.total, + party: demLeads ? t("midtermsHubDemocrat") : t("midtermsHubRepublican"), + b: (chunks) => ( + + {chunks} + + ), + })} +

+ ); +}; + +export default RaceLeanSummary; diff --git a/front_end/src/app/(main)/midterms-2026/components/responsive_map.tsx b/front_end/src/app/(main)/midterms-2026/components/responsive_map.tsx index 552f3a6da3..a1c4241215 100644 --- a/front_end/src/app/(main)/midterms-2026/components/responsive_map.tsx +++ b/front_end/src/app/(main)/midterms-2026/components/responsive_map.tsx @@ -4,6 +4,7 @@ import { FC, useState } from "react"; import ChamberTabs, { ChamberView } from "./chamber_tabs"; import GeographicMap from "./geographic_map"; +import RaceLeanSummary from "./race_lean_summary"; import TileMap from "./tile_map"; import { SenateRaceWithQuestion } from "../helpers/post_utils"; @@ -15,6 +16,9 @@ type Props = { const ResponsiveMap: FC = ({ senateRaces, governorRaces }) => { const [view, setView] = useState("senate"); const races = view === "senate" ? senateRaces : governorRaces; + // Governor-only: the Senate map deliberately has no summary line. + const summarySlot = + view === "governor" ? : null; return ( <> @@ -25,6 +29,7 @@ const ResponsiveMap: FC = ({ senateRaces, governorRaces }) => { } + summarySlot={summarySlot} />
@@ -32,6 +37,7 @@ const ResponsiveMap: FC = ({ senateRaces, governorRaces }) => {
+ {summarySlot &&
{summarySlot}
}
diff --git a/front_end/src/app/(main)/midterms-2026/components/seat_distribution_chart.tsx b/front_end/src/app/(main)/midterms-2026/components/seat_distribution_chart.tsx index d893799970..aa219d52c9 100644 --- a/front_end/src/app/(main)/midterms-2026/components/seat_distribution_chart.tsx +++ b/front_end/src/app/(main)/midterms-2026/components/seat_distribution_chart.tsx @@ -1,7 +1,7 @@ "use client"; import { useTranslations } from "next-intl"; -import { FC, useId, useMemo } from "react"; +import { FC, useCallback, useId, useMemo, useRef, useState } from "react"; import { VictoryArea, VictoryAxis, @@ -9,6 +9,7 @@ import { VictoryChart, VictoryLabel, VictoryLine, + VictoryScatter, VictoryTooltip, VictoryVoronoiContainer, } from "victory"; @@ -81,8 +82,65 @@ const HOVER_BAR_COLOR_DARK = "#E2E8F0"; const HOVER_BAR_WIDTH = 1; const HOVER_BAR_ACTIVE_OPACITY = 0.75; +// Shared flyout geometry for both the hover tooltip and the median callout. +const TOOLTIP_FLYOUT_PADDING = { top: 11, bottom: 10, left: 12, right: 12 }; +const TOOLTIP_CORNER_RADIUS = 6; +const TOOLTIP_POINTER_LENGTH = 10; +const HOVER_FLYOUT_OPACITY = 0.9; +// Per-line line-heights for the hover tooltip's two lines. The second value is +// bumped over the first to open ~2px more gap between them (line 2 is 14px, so +// 1.3 -> 1.44 moves its baseline 18.2px -> 20.2px). +const HOVER_TOOLTIP_LINE_HEIGHTS: [number, number] = [1.3, 1.44]; + +// Median callout. The flyout inverts against the page — black on light, white on +// dark — so the party inks have to invert too: the light page needs the *light* +// party swatch to read on its black flyout, and the dark page needs the darker +// one on white. That's the opposite of every other party color in this file, +// which is why these don't reuse demFill / demStroke. +const MEDIAN_INK_LIGHT = "#000000"; +const MEDIAN_INK_DARK = "#ffffff"; +const MEDIAN_FLYOUT_OPACITY = 0.8; +const MEDIAN_FONT_SIZE = 14; +const MEDIAN_MARKER_SIZE = 4; +const MEDIAN_MARKER_STROKE_WIDTH = 1.5; + type Point = { x: number; y: number }; +/** + * One-line, two-ink tooltip label: "Median:" in the flyout's foreground color + * followed by the seat value in its party color. VictoryLabel's array API only + * stacks lines vertically, so the two runs need to be sibling tspans here. + * VictoryTooltip still sizes its flyout from the concatenated `text` prop it was + * given, so auto-sizing keeps working. + */ +const MedianTooltipLabel: FC<{ + x?: number; + y?: number; + labelText: string; + valueText: string; + labelFill: string; + valueFill: string; +}> = ({ x, y, labelText, valueText, labelFill, valueFill }) => ( + + {/* Non-breaking space so the SVG renderer can't collapse the gap between + the two runs. */} + {`${labelText} `} + + {valueText} + + +); + const SeatDistributionChart: FC = ({ post, cdfOverride, @@ -101,6 +159,32 @@ const SeatDistributionChart: FC = ({ const reactId = useId().replace(/:/g, ""); const fillGradientId = `seat-fill-${reactId}`; const strokeGradientId = `seat-stroke-${reactId}`; + // The median callout is a default-state affordance: it yields the moment a bin + // is hovered so it never stacks with the hover tooltip. + const [isBinHovered, setIsBinHovered] = useState(false); + // Victory's voronoi handleMouseMove calls onActivated(newPoints) and then + // onDeactivated(previousPoints) back-to-back in the same tick whenever the + // active bin changes. Without this flag the paired onDeactivated would + // immediately undo every activation and the callout would never hide. Only the + // genuine clears — pointer out of bounds, or off the container — arrive + // without a preceding activation. + const justActivatedRef = useRef(false); + const handleBinActivated = useCallback((points: unknown[]) => { + const active = points.length > 0; + justActivatedRef.current = active; + setIsBinHovered(active); + }, []); + const handleBinDeactivated = useCallback(() => { + if (justActivatedRef.current) { + justActivatedRef.current = false; + return; + } + setIsBinHovered(false); + }, []); + const handlePointerLeave = useCallback(() => { + justActivatedRef.current = false; + setIsBinHovered(false); + }, []); const question = post.question as QuestionWithNumericForecasts | undefined; @@ -274,6 +358,38 @@ const SeatDistributionChart: FC = ({ const neutralFill = isDark ? EVEN_BAR_FILL_DARK : EVEN_BAR_FILL_LIGHT; const hoverBarColor = isDark ? HOVER_BAR_COLOR_DARK : HOVER_BAR_COLOR_LIGHT; const evenTextColor = isDark ? EVEN_TEXT_COLOR_DARK : EVEN_TEXT_COLOR_LIGHT; + const chartBg = isDark + ? MIDTERMS_COLORS.cardBgDark + : MIDTERMS_COLORS.cardBgLight; + + // Median callout. Both questions are Discrete, so the median lands on an + // integer bin center and the marker snaps to a bar rather than floating + // between two. Negative = Dem advantage, positive = Rep (see data.ts). + const medianInk = isDark ? MEDIAN_INK_DARK : MEDIAN_INK_LIGHT; + const medianLabelFill = isDark ? "#262f38" : "#ffffff"; + const medianSeats = Math.round(quartileXs.median); + // A median of exactly zero gets no callout at all: the EVEN bin already owns + // that spot, so a marker there would only restate it — and labelling the two + // separately at the same x invites reading them as the same claim (the EVEN + // bar is P(advantage = 0); the median is where the CDF crosses 50%). Returns + // on its own as soon as the median moves off zero. + const showMedian = medianSeats !== 0; + const medianLabelText = t("midtermsHubMedianLabel"); + const medianValueText = + medianSeats < 0 + ? t("midtermsHubMedianDem", { count: Math.abs(medianSeats) }) + : t("midtermsHubMedianRep", { count: medianSeats }); + // Keyed off the flyout's own background, not the page: the light page's black + // flyout needs the *Dark* swatches (the pair designed to read on dark + // surfaces), and the dark page's white flyout needs the darker Border pair. + const medianValueFill = + medianSeats < 0 + ? isDark + ? MIDTERMS_COLORS.demBorder + : MIDTERMS_COLORS.demPrimaryDark + : isDark + ? MIDTERMS_COLORS.repBorder + : MIDTERMS_COLORS.repPrimaryDark; const BAR_FILL_OPACITY = 0.7; const BAR_FILL_OPACITY_HOVER = 1; @@ -385,10 +501,14 @@ const SeatDistributionChart: FC = ({ const tooltipComponent = ( } + cornerRadius={TOOLTIP_CORNER_RADIUS} + flyoutPadding={TOOLTIP_FLYOUT_PADDING} + flyoutStyle={{ + fill: tooltipBgFill, + fillOpacity: HOVER_FLYOUT_OPACITY, + stroke: "transparent", + }} + labelComponent={} style={[ { fill: tooltipTextFill, @@ -405,8 +525,40 @@ const SeatDistributionChart: FC = ({ fontVariantNumeric: "tabular-nums", }, ]} - pointerLength={10} + pointerLength={TOOLTIP_POINTER_LENGTH} + constrainToVisibleArea + /> + ); + + const medianTooltipComponent = ( + + } /> ); @@ -444,6 +596,9 @@ const SeatDistributionChart: FC = ({ className="relative w-full" ref={containerRef} aria-label={ariaTitle} + // Safety net: Victory's onDeactivated doesn't reliably fire when the + // pointer leaves the plot quickly, which would strand the median hidden. + onMouseLeave={handlePointerLeave} role="img" > {/* Hard-edge linear gradient that snaps from dem fill to rep fill at @@ -491,10 +646,17 @@ const SeatDistributionChart: FC = ({ labels={formatTooltipLabel} labelComponent={tooltipComponent} mouseFollowTooltips={false} + onActivated={handleBinActivated} + onDeactivated={handleBinDeactivated} voronoiBlacklist={ isDiscrete - ? ["even-connector", "oob-divider-l", "oob-divider-r"] - : ["area", "q-l", "q-m", "q-u"] + ? [ + "median-marker", + "even-connector", + "oob-divider-l", + "oob-divider-r", + ] + : ["median-marker", "area", "q-l", "q-m", "q-u"] } // Let the page scroll vertically through the chart on touch; the // chart only consumes horizontal moves (for the tooltip). Mirrors @@ -707,6 +869,29 @@ const SeatDistributionChart: FC = ({ /> )} + {/* Median marker — a dot on the x-axis at the median bin carrying an + always-on flyout. Rendered last so it paints above the bars, and + unmounted entirely while a bin is hovered so it never stacks with + the hover tooltip. */} + {showMedian && !isBinHovered && ( + `${medianLabelText} ${medianValueText}`} + labelComponent={medianTooltipComponent} + /> + )} + {/* Chart title (SENATE / HOUSE) — rendered inside the SVG (not as an HTML overlay) so the voronoi tooltip paints above it. */} = 50` split matches the one + * `state_tooltip.tsx` uses, so a summary can never disagree with a tooltip. + * Races with no forecast are excluded from every count, and `total` is the sum + * of the two sides rather than `races.length` so the numbers always reconcile. + * Null when nothing is forecast yet. + */ +export function summarizeRaceLeans( + races: SenateRaceWithQuestion[] +): { dem: number; rep: number; total: number } | null { + let dem = 0; + let rep = 0; + for (const race of races) { + if (race.demWinPct == null) continue; + if (race.demWinPct >= 50) dem += 1; + else rep += 1; + } + const total = dem + rep; + return total > 0 ? { dem, rep, total } : null; +} diff --git a/front_end/src/app/(main)/midterms-2026/page.tsx b/front_end/src/app/(main)/midterms-2026/page.tsx index 4259e25aaf..5e5f362ed5 100644 --- a/front_end/src/app/(main)/midterms-2026/page.tsx +++ b/front_end/src/app/(main)/midterms-2026/page.tsx @@ -6,6 +6,7 @@ import { getPublicSettings } from "@/utils/public_settings.server"; import CommunityInsightsSection from "./sections/community_insights"; import ElectionsMapSection from "./sections/elections_map_section"; import ElectoralConsequencesSection from "./sections/electoral_consequences"; +import EngagementSection from "./sections/engagement_section"; import FooterSection from "./sections/footer"; import HeroSection from "./sections/hero"; import SeatDistributionsSection from "./sections/seat_distributions"; @@ -47,6 +48,7 @@ export default function MidtermsHubPage() { +
diff --git a/front_end/src/app/(main)/midterms-2026/sections/engagement_section.tsx b/front_end/src/app/(main)/midterms-2026/sections/engagement_section.tsx new file mode 100644 index 0000000000..b3f1dd47b1 --- /dev/null +++ b/front_end/src/app/(main)/midterms-2026/sections/engagement_section.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { faXTwitter } from "@fortawesome/free-brands-svg-icons"; +import { faCommentDots, faCopy } from "@fortawesome/free-regular-svg-icons"; +import { faChartBar } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import Link from "next/link"; +import { useTranslations } from "next-intl"; + +import { NewsletterSubscribeCard } from "@/app/(main)/labor-hub/components/newsletter_subscribe_card"; +import { useModal } from "@/contexts/modal_context"; +import { useCopyUrl, useShareOnTwitterLink } from "@/hooks/share"; + +// Mailjet contact list key for this hub — resolved in app/(main)/actions.ts. +const NEWSLETTER_LIST_KEY = "midterms"; + +function PartnerCard() { + const t = useTranslations(); + const { setCurrentModal } = useModal(); + + return ( +
+
+
+ +

+ {t("midtermsHubEngagementReachTitle")} +

+
+

+ {t("midtermsHubEngagementReachBody")} +

+
+
+ + {t("midtermsHubEngagementViewServices")} + + +
+
+ ); +} + +function ShareCard() { + const t = useTranslations(); + const copyUrl = useCopyUrl({ includeHash: false }); + const shareOnTwitterLink = useShareOnTwitterLink( + t("midtermsHubEngagementTweetText"), + { includeHash: false } + ); + + return ( +
+
+
+ +

+ {t("midtermsHubEngagementShareTitle")} +

+
+

+ {t("midtermsHubEngagementShareBody")} +

+
+ +
+ ); +} + +export default function EngagementSection() { + const t = useTranslations(); + + return ( +
+ + + +
+ ); +} From 3cabf0d3409379e462265003b0685d1f41a12616 Mon Sep 17 00:00:00 2001 From: Atakan Seckin Date: Wed, 12 Aug 2026 08:57:39 +0200 Subject: [PATCH 2/6] Midterms: replace balance-of-power snapshot with forecast timelines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hub's right panel showed only point-in-time numbers, so a reader couldn't tell whether "Democrats 86.3% to hold the House" was a sharp move or had been flat for months. The panel now shows three community-prediction timelines — House, Senate, and the four-way congressional outcome — covering ~18 months of history (333 aggregation points back to Jan 2025). No new fetching: getPostsWithCP already sets include_cp_history, so the history for #34484 was arriving and being discarded. - helpers/build_control_timelines.ts turns that history into ChoiceItem series, summing options per series. House = DD+RD / RR+DR and Senate = DD+DR / RR+RD, the same sums chamber_control_card.tsx uses, so each line's last point lands exactly on the percentage the cards showed (verified: 86.3/13.7, 37.3/62.7). Deliberately not built on generateChoiceItemsFromMultipleChoiceForecast, which routes through getAllOptionsHistory — that returns only the last entry of question.options when options_history is absent and would silently collapse a four-option question to one series. - Rendered with the shared GroupChart: individual lines, a dot pinned to each endpoint that tracks the cursor on hover, and no axes. Values live in the legend instead, showing the current figure at rest and the hovered point's figure while the cursor is in the plot. - GroupChart gains additive hideYAxis / hideXAxis props (both default false, so no existing call site changes). Its axis tick styles are set inline on the VictoryAxis and can't be reached via extraTheme, hence props rather than a theme override. hideXAxis makes permanent what the component already did while the cursor was active; pair it with showCursorLabel so the hovered date is the only x value ever shown. - The four congressional outcomes use MULTIPLE_CHOICE_COLOR_SCALE; two blues and two reds were indistinguishable as four lines. - Layout: one rule divides the map from the panel and runs the full card height, and the map column now carries the same padding as the panel so it's inset equally on all sides rather than butting against the rule. The snapshot cards (chamber_control_card, congress_outcome_card) are no longer rendered but are left in place unchanged. Co-Authored-By: Claude Opus 5 --- front_end/messages/cs.json | 1 + front_end/messages/en.json | 1 + front_end/messages/es.json | 1 + front_end/messages/pt.json | 1 + front_end/messages/zh-TW.json | 1 + front_end/messages/zh.json | 1 + .../components/balance_of_power_timelines.tsx | 237 ++++++++++++++++++ .../components/geographic_map.tsx | 5 +- .../components/responsive_map.tsx | 2 +- .../helpers/build_control_timelines.ts | 143 +++++++++++ .../sections/elections_map_section.tsx | 23 +- .../src/components/charts/group_chart.tsx | 20 +- 12 files changed, 420 insertions(+), 16 deletions(-) create mode 100644 front_end/src/app/(main)/midterms-2026/components/balance_of_power_timelines.tsx create mode 100644 front_end/src/app/(main)/midterms-2026/helpers/build_control_timelines.ts diff --git a/front_end/messages/cs.json b/front_end/messages/cs.json index fac1cd6ce5..5fdb918344 100644 --- a/front_end/messages/cs.json +++ b/front_end/messages/cs.json @@ -2326,6 +2326,7 @@ "midtermsHubSeatAdvantageOverTooltip": "Náskok více než {count} mandátů", "midtermsHubProbabilityTooltip": "{value}% pravděpodobnost", "midtermsHubEvenTooltip": "Remíza", + "midtermsHubTimelineUnavailable": "Historie předpovědí není dostupná", "midtermsHubEngagementReachTitle": "Kontaktujte Metaculus", "midtermsHubEngagementReachBody": "Máte postřehy, dotazy nebo jste našli něco nejasného? Vaše zpětná vazba nám pomáhá tento přehled zlepšovat.", "midtermsHubEngagementViewServices": "Zobrazit služby", diff --git a/front_end/messages/en.json b/front_end/messages/en.json index 22e97f9e53..2a60065a0c 100644 --- a/front_end/messages/en.json +++ b/front_end/messages/en.json @@ -2395,6 +2395,7 @@ "midtermsHubSeatAdvantageOverTooltip": ">{count} seat advantage", "midtermsHubProbabilityTooltip": "{value}% probability", "midtermsHubEvenTooltip": "Even", + "midtermsHubTimelineUnavailable": "Forecast history unavailable", "midtermsHubEngagementReachTitle": "Reach out to Metaculus", "midtermsHubEngagementReachBody": "Have thoughts, questions, or spotted something unclear? Your input helps us improve this dashboard.", "midtermsHubEngagementViewServices": "View Services", diff --git a/front_end/messages/es.json b/front_end/messages/es.json index 5ca7499495..65c6b494fb 100644 --- a/front_end/messages/es.json +++ b/front_end/messages/es.json @@ -2326,6 +2326,7 @@ "midtermsHubSeatAdvantageOverTooltip": ">{count} escaños de ventaja", "midtermsHubProbabilityTooltip": "{value}% de probabilidad", "midtermsHubEvenTooltip": "Empate", + "midtermsHubTimelineUnavailable": "Historial de previsiones no disponible", "midtermsHubEngagementReachTitle": "Ponte en contacto con Metaculus", "midtermsHubEngagementReachBody": "¿Tienes ideas, preguntas o has detectado algo confuso? Tus comentarios nos ayudan a mejorar este panel.", "midtermsHubEngagementViewServices": "Ver servicios", diff --git a/front_end/messages/pt.json b/front_end/messages/pt.json index 6052e99c9b..6fa9773123 100644 --- a/front_end/messages/pt.json +++ b/front_end/messages/pt.json @@ -2324,6 +2324,7 @@ "midtermsHubSeatAdvantageOverTooltip": ">{count} cadeiras de vantagem", "midtermsHubProbabilityTooltip": "{value}% de probabilidade", "midtermsHubEvenTooltip": "Empate", + "midtermsHubTimelineUnavailable": "Histórico de previsões indisponível", "midtermsHubEngagementReachTitle": "Fale com a Metaculus", "midtermsHubEngagementReachBody": "Tem ideias, dúvidas ou encontrou algo pouco claro? Sua opinião nos ajuda a melhorar este painel.", "midtermsHubEngagementViewServices": "Ver serviços", diff --git a/front_end/messages/zh-TW.json b/front_end/messages/zh-TW.json index c55a60100d..508a04399c 100644 --- a/front_end/messages/zh-TW.json +++ b/front_end/messages/zh-TW.json @@ -2323,6 +2323,7 @@ "midtermsHubSeatAdvantageOverTooltip": "領先超過 {count} 席", "midtermsHubProbabilityTooltip": "{value}% 機率", "midtermsHubEvenTooltip": "持平", + "midtermsHubTimelineUnavailable": "暫無預測歷史", "midtermsHubEngagementReachTitle": "聯絡 Metaculus", "midtermsHubEngagementReachBody": "有想法、疑問,或發現了不清楚的地方?您的回饋能幫助我們改進這個面板。", "midtermsHubEngagementViewServices": "查看服務", diff --git a/front_end/messages/zh.json b/front_end/messages/zh.json index d42e0139f5..d645601e13 100644 --- a/front_end/messages/zh.json +++ b/front_end/messages/zh.json @@ -2327,6 +2327,7 @@ "midtermsHubSeatAdvantageOverTooltip": "领先超过 {count} 席", "midtermsHubProbabilityTooltip": "{value}% 概率", "midtermsHubEvenTooltip": "持平", + "midtermsHubTimelineUnavailable": "暂无预测历史", "midtermsHubEngagementReachTitle": "联系 Metaculus", "midtermsHubEngagementReachBody": "有想法、疑问,或发现了不清楚的地方?您的反馈能帮助我们改进这个面板。", "midtermsHubEngagementViewServices": "查看服务", diff --git a/front_end/src/app/(main)/midterms-2026/components/balance_of_power_timelines.tsx b/front_end/src/app/(main)/midterms-2026/components/balance_of_power_timelines.tsx new file mode 100644 index 0000000000..c469987575 --- /dev/null +++ b/front_end/src/app/(main)/midterms-2026/components/balance_of_power_timelines.tsx @@ -0,0 +1,237 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { FC, useMemo, useState } from "react"; + +import GroupChart from "@/components/charts/group_chart"; +import { MULTIPLE_CHOICE_COLOR_SCALE } from "@/constants/colors"; +import { TimelineChartZoomOption } from "@/types/charts"; +import { PostWithForecasts } from "@/types/post"; +import { + QuestionType, + QuestionWithMultipleChoiceForecasts, +} from "@/types/question"; +import { ThemeColor } from "@/types/theme"; + +import { MIDTERMS_COLORS } from "../constants"; +import { CONGRESS_OUTCOME_LABELS } from "../data"; +import { + buildControlTimeline, + ControlTimeline, +} from "../helpers/build_control_timelines"; +import { useIsDark } from "../helpers/use_is_dark"; + +// Deliberately compact for the ~320px-wide sidebar column. Of this, PLOT_TOP +// (10px) and the axis band (20px) are chrome, so the trend itself gets ~72px — +// enough to read direction and crossings, which is all these are for. The +// precise numbers live in the legend, not the axes. +const CHART_HEIGHT = 102; + +const DEM: ThemeColor = { + DEFAULT: MIDTERMS_COLORS.demPrimary, + dark: MIDTERMS_COLORS.demPrimaryDark, +}; +const REP: ThemeColor = { + DEFAULT: MIDTERMS_COLORS.repPrimary, + dark: MIDTERMS_COLORS.repPrimaryDark, +}; + +// The four congress outcomes use the shared multiple-choice palette rather than +// party tints: two blues and two reds sit too close together to tell apart as +// four separate lines. +const NEUTRAL_FALLBACK: ThemeColor = { + DEFAULT: MIDTERMS_COLORS.spectrumNeutral, + dark: MIDTERMS_COLORS.spectrumNeutral, +}; +const MC = (i: number): ThemeColor => + MULTIPLE_CHOICE_COLOR_SCALE[i] ?? NEUTRAL_FALLBACK; + +type Props = { + /** The congress-control post (#34484) — the source for all three timelines. */ + post: PostWithForecasts | null; +}; + +const BalanceOfPowerTimelines: FC = ({ post }) => { + const t = useTranslations(); + const question = post?.question as + | QuestionWithMultipleChoiceForecasts + | undefined; + + const demLabel = t("midtermsHubPartyDemocrats"); + const repLabel = t("midtermsHubPartyRepublicans"); + + const timelines = useMemo(() => { + // Same option sums as chamber_control_card.tsx, so the last point of each + // series lands on the percentage the snapshot view shows. + const house = buildControlTimeline(question, [ + { + label: demLabel, + color: DEM, + optionLabels: [CONGRESS_OUTCOME_LABELS.DD, CONGRESS_OUTCOME_LABELS.RD], + }, + { + label: repLabel, + color: REP, + optionLabels: [CONGRESS_OUTCOME_LABELS.RR, CONGRESS_OUTCOME_LABELS.DR], + }, + ]); + const senate = buildControlTimeline(question, [ + { + label: demLabel, + color: DEM, + optionLabels: [CONGRESS_OUTCOME_LABELS.DD, CONGRESS_OUTCOME_LABELS.DR], + }, + { + label: repLabel, + color: REP, + optionLabels: [CONGRESS_OUTCOME_LABELS.RR, CONGRESS_OUTCOME_LABELS.RD], + }, + ]); + // The four outcomes read directly — no summing. + const congress = buildControlTimeline(question, [ + { + label: t("midtermsHubOutcomeRepRep"), + color: MC(0), + optionLabels: [CONGRESS_OUTCOME_LABELS.RR], + }, + { + label: t("midtermsHubOutcomeRepDem"), + color: MC(1), + optionLabels: [CONGRESS_OUTCOME_LABELS.RD], + }, + { + label: t("midtermsHubOutcomeDemRep"), + color: MC(2), + optionLabels: [CONGRESS_OUTCOME_LABELS.DR], + }, + { + label: t("midtermsHubOutcomeDemDem"), + color: MC(3), + optionLabels: [CONGRESS_OUTCOME_LABELS.DD], + }, + ]); + return { house, senate, congress }; + }, [question, demLabel, repLabel, t]); + + const unavailableLabel = t("midtermsHubTimelineUnavailable"); + + return ( + // Even spacing between the three charts, flush to the panel's own padding at + // the outer edges. +
+ + + +
+ ); +}; + +const TimelineBlock: FC<{ + title: string; + timeline: ControlTimeline | null; + unavailableLabel: string; +}> = ({ title, timeline, unavailableLabel }) => { + // Null = not hovering, so the legend shows the latest value. GroupChart reports + // the hovered timestamp here and we look each series up at that point. + const [cursorTimestamp, setCursorTimestamp] = useState(null); + + return ( +
+

+ {title} +

+ {timeline ? ( +
setCursorTimestamp(null)}> + + setCursorTimestamp(value)} + /> +
+ ) : ( +
+ {unavailableLabel} +
+ )} +
+ ); +}; + +const TimelineLegend: FC<{ + timeline: ControlTimeline; + cursorTimestamp: number | null; +}> = ({ timeline, cursorTimestamp }) => { + const isDark = useIsDark(); + const index = resolveIndex(timeline.timestamps, cursorTimestamp); + + return ( +
+ {timeline.choiceItems.map((item) => { + const value = item.aggregationValues[index]; + return ( + + + {item.choice} + + {value == null ? "—" : `${(value * 100).toFixed(1)}%`} + + + ); + })} +
+ ); +}; + +/** Index of the last point at or before the cursor; the latest point when idle. */ +function resolveIndex( + timestamps: number[], + cursorTimestamp: number | null +): number { + const lastIndex = timestamps.length - 1; + if (cursorTimestamp == null) return lastIndex; + for (let i = lastIndex; i >= 0; i--) { + if ((timestamps[i] ?? 0) <= cursorTimestamp) return i; + } + return 0; +} + +export default BalanceOfPowerTimelines; diff --git a/front_end/src/app/(main)/midterms-2026/components/geographic_map.tsx b/front_end/src/app/(main)/midterms-2026/components/geographic_map.tsx index dc98f79e8f..9a6d02ab9b 100644 --- a/front_end/src/app/(main)/midterms-2026/components/geographic_map.tsx +++ b/front_end/src/app/(main)/midterms-2026/components/geographic_map.tsx @@ -238,7 +238,10 @@ const GeographicMap: FC = ({ races, tabsSlot, summarySlot }) => { row so a wrapping summary grows over the map instead of pushing it down, while the gap keeps it clear of both neighbors. The paddings reproduce the standalone positions these two used to have. */} -
+ {/* Flush to the container: the map column's own padding provides the inset, + so the legend clears the dividing rule by the same 40px as everything + else. */} +
{tabsSlot &&
{tabsSlot}
}
{summarySlot} diff --git a/front_end/src/app/(main)/midterms-2026/components/responsive_map.tsx b/front_end/src/app/(main)/midterms-2026/components/responsive_map.tsx index a1c4241215..36b9c989e4 100644 --- a/front_end/src/app/(main)/midterms-2026/components/responsive_map.tsx +++ b/front_end/src/app/(main)/midterms-2026/components/responsive_map.tsx @@ -32,7 +32,7 @@ const ResponsiveMap: FC = ({ senateRaces, governorRaces }) => { summarySlot={summarySlot} />
-
+
diff --git a/front_end/src/app/(main)/midterms-2026/helpers/build_control_timelines.ts b/front_end/src/app/(main)/midterms-2026/helpers/build_control_timelines.ts new file mode 100644 index 0000000000..cc3f35dc4e --- /dev/null +++ b/front_end/src/app/(main)/midterms-2026/helpers/build_control_timelines.ts @@ -0,0 +1,143 @@ +import { ChoiceItem } from "@/types/choices"; +import { + MultipleChoiceAggregateForecast, + QuestionWithMultipleChoiceForecasts, +} from "@/types/question"; +import { ThemeColor } from "@/types/theme"; + +export type ControlSeriesConfig = { + /** Option labels whose probabilities are summed into this one series. */ + optionLabels: string[]; + /** Legend / tooltip label. */ + label: string; + color: ThemeColor; +}; + +export type ControlTimeline = { + timestamps: number[]; + choiceItems: ChoiceItem[]; +}; + +/** + * Builds a community-prediction timeline from a multiple-choice question, + * summing one or more options into each series. + * + * Deliberately not built on `generateChoiceItemsFromMultipleChoiceForecast`: + * that routes through `getAllOptionsHistory`, which returns only the *last* + * entry of `question.options` when `options_history` is absent — so it can + * silently collapse a four-option question to a single series. Summing has no + * equivalent there either; the closest thing, `buildChoicesWithOthers`, only + * folds *inactive* options into a gray bucket. + * + * Option indices are matched by label, case-insensitively — the same convention + * `getMultipleChoiceOptionProbability` uses — so these are the same sums the + * snapshot cards display and the two views cannot drift apart. + * + * Client-only: the timestamp grid compares `end_time` against the current + * clock, so building this during SSR and again on the client would risk a + * hydration mismatch. + */ +export function buildControlTimeline( + question: QuestionWithMultipleChoiceForecasts | null | undefined, + series: ControlSeriesConfig[] +): ControlTimeline | null { + if (!question || !series.length) return null; + + const history = + question.aggregations?.[question.default_aggregation_method]?.history; + if (!history?.length) return null; + + const options = question.options ?? []; + const seriesIndices = series.map((s) => + s.optionLabels.map((label) => + options.findIndex((o) => o.toLowerCase() === label.toLowerCase()) + ) + ); + // Fail closed, like the snapshot cards do: if an option label no longer + // matches, we can't reproduce its number, and a silently understated line is + // worse than no chart. + if (seriesIndices.some((group) => group.some((i) => i < 0))) return null; + + const timestamps = collectTimestamps(history); + // A single point can't draw a trend. + if (timestamps.length < 2) return null; + + const choiceItems: ChoiceItem[] = series.map((config, i) => { + const optionIndices = seriesIndices[i] ?? []; + const aggregationValues: (number | null)[] = []; + const aggregationForecasterCounts: number[] = []; + + for (const timestamp of timestamps) { + const entry = findCoveringForecast(history, timestamp); + aggregationValues.push(sumCenters(entry, optionIndices)); + aggregationForecasterCounts.push(entry?.forecaster_count ?? 0); + } + + return { + choice: config.label, + label: config.label, + color: config.color, + highlighted: false, + active: true, + resolution: null, + aggregationTimestamps: timestamps, + aggregationValues, + // No interval band: the CI around a sum of options is not the sum of + // their CIs, so synthesizing one would be a fabrication. MultipleChoiceChart + // never reads these anyway — its y1/y2 come from its own stacking pass. + aggregationMinValues: timestamps.map(() => null), + aggregationMaxValues: timestamps.map(() => null), + aggregationForecasterCounts, + userTimestamps: [], + userValues: [], + }; + }); + + return { timestamps, choiceItems }; +} + +/** + * Sorted, de-duplicated grid of every point where the aggregation changed. + * `end_time` only counts once it's in the past, matching how the shared + * `collectSortedTimestamps` treats still-open windows. + */ +function collectTimestamps( + history: MultipleChoiceAggregateForecast[] +): number[] { + const nowSeconds = Date.now() / 1000; + const unique = new Set(); + for (const forecast of history) { + unique.add(forecast.start_time); + if (forecast.end_time != null && forecast.end_time <= nowSeconds) { + unique.add(forecast.end_time); + } + } + return [...unique].sort((a, b) => a - b); +} + +function findCoveringForecast( + history: MultipleChoiceAggregateForecast[], + timestamp: number +): MultipleChoiceAggregateForecast | undefined { + return history.find( + (forecast) => + forecast.start_time <= timestamp && + (forecast.end_time === null || forecast.end_time > timestamp) + ); +} + +/** Null if any contributing option is missing, so a gap stays a gap. */ +function sumCenters( + entry: MultipleChoiceAggregateForecast | undefined, + optionIndices: number[] +): number | null { + const centers = entry?.centers; + if (!centers) return null; + let total = 0; + for (const index of optionIndices) { + const value = centers[index]; + if (value == null) return null; + total += value; + } + return Number(total.toFixed(6)); +} diff --git a/front_end/src/app/(main)/midterms-2026/sections/elections_map_section.tsx b/front_end/src/app/(main)/midterms-2026/sections/elections_map_section.tsx index 9776005efd..355ee67cda 100644 --- a/front_end/src/app/(main)/midterms-2026/sections/elections_map_section.tsx +++ b/front_end/src/app/(main)/midterms-2026/sections/elections_map_section.tsx @@ -1,7 +1,6 @@ import { SectionCard } from "@/app/(main)/labor-hub/components/section"; -import ChamberControlCard from "../components/chamber_control_card"; -import CongressOutcomeCard from "../components/congress_outcome_card"; +import BalanceOfPowerTimelines from "../components/balance_of_power_timelines"; import ResponsiveMap from "../components/responsive_map"; import { fetchChamberData, @@ -19,20 +18,22 @@ export default async function ElectionsMapSection() { return ( -
- {/* Map column: edge-to-edge of the SectionCard on md+. */} -
+
+ {/* Map column. Padding matches the panel's, so the map is inset the same + 40px from the dividing rule as the panel's content is — and the tabs, + summary and legend overlay inherit it instead of setting their own. */} +
- {/* Sidebar column: capped at 30% of the container with its own - internal padding so the cards stay inset from the white card - edges. */} -
- - + {/* Balance-of-power column. One rule divides it from the map and runs the + full height — grid items stretch, so it meets both card edges. Below + md the layout is a single column, so the same rule becomes the + horizontal separator above the panel instead. */} +
+
diff --git a/front_end/src/components/charts/group_chart.tsx b/front_end/src/components/charts/group_chart.tsx index 6c1ff00901..3ec313a677 100644 --- a/front_end/src/components/charts/group_chart.tsx +++ b/front_end/src/components/charts/group_chart.tsx @@ -102,6 +102,13 @@ type Props = { cursorTimestamp?: number | null; forecastAvailability?: ForecastAvailability; forceShowLinePoints?: boolean; + /** Hide the y-axis tick labels and reclaim the space they reserve. Gridlines + * stay. For compact charts that surface their values elsewhere. */ + hideYAxis?: boolean; + /** Hide the x-axis tick labels permanently, not just while the cursor is + * active. Pair with `showCursorLabel` so the hovered date is the only x value + * ever shown. */ + hideXAxis?: boolean; forFeedPage?: boolean; isEmbedded?: boolean; chartTitle?: ReactNode; @@ -148,6 +155,8 @@ const GroupChart: FC = ({ cursorTimestamp, forecastAvailability, forceShowLinePoints = false, + hideYAxis = false, + hideXAxis = false, forFeedPage, isEmbedded = false, chartTitle, @@ -279,8 +288,11 @@ const GroupChart: FC = ({ return getAxisRightPadding(yScale, tickLabelFontSize as number, yLabel); }, [yScale, tickLabelFontSize, yLabel]); const maxRightPadding = useMemo(() => { + // With the labels hidden there's nothing to reserve width for, but keep the + // floor so the last x-axis tick still has room. + if (hideYAxis) return MIN_RIGHT_PADDING; return Math.max(rightPadding, MIN_RIGHT_PADDING); - }, [rightPadding, MIN_RIGHT_PADDING]); + }, [hideYAxis, rightPadding, MIN_RIGHT_PADDING]); const chartPadding = useMemo( () => ({ left: leftPadding, @@ -597,7 +609,9 @@ const GroupChart: FC = ({ 4, textAnchor: "end", fontSize: tickLabelFontSize, - fill: getThemeColor(METAC_COLORS.gray["700"]), + fill: hideYAxis + ? "transparent" + : getThemeColor(METAC_COLORS.gray["700"]), }, axis: { stroke: "transparent", @@ -621,7 +635,7 @@ const GroupChart: FC = ({ "" : xScale.tickFormat } From aa67181912ac3775d83eda5cf64bb959d932e79e Mon Sep 17 00:00:00 2001 From: Atakan Seckin Date: Wed, 12 Aug 2026 11:43:41 +0200 Subject: [PATCH 3/6] Midterms: soften the map/panel dividing rule to blue-200 blue-300 read heavier than the rest of the section's chrome now that it's the only structural line in the card. Co-Authored-By: Claude Opus 5 --- .../app/(main)/midterms-2026/sections/elections_map_section.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/front_end/src/app/(main)/midterms-2026/sections/elections_map_section.tsx b/front_end/src/app/(main)/midterms-2026/sections/elections_map_section.tsx index 355ee67cda..d43681f2b2 100644 --- a/front_end/src/app/(main)/midterms-2026/sections/elections_map_section.tsx +++ b/front_end/src/app/(main)/midterms-2026/sections/elections_map_section.tsx @@ -32,7 +32,7 @@ export default async function ElectionsMapSection() { full height — grid items stretch, so it meets both card edges. Below md the layout is a single column, so the same rule becomes the horizontal separator above the panel instead. */} -
+
From 14889a9ab5bf2efbe6b5459543337cac232c36fa Mon Sep 17 00:00:00 2001 From: Atakan Seckin Date: Wed, 12 Aug 2026 12:18:37 +0200 Subject: [PATCH 4/6] Midterms: same-tab insight links, single-pass timeline coverage, localized copy toast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Insight cards navigate in the current tab instead of opening a new one. Their corner glyph changes from the open-in-new-window arrow to a plain right arrow, which is what the link now actually does. - build_control_timelines resolves each timestamp's covering forecast window once with a monotonic cursor and shares the result across every series, replacing a history.find scan run per timestamp per series. Both sequences ascend, so this is a single pass rather than series x timestamps x history. Verified behavior-identical against the previous implementation: 3 charts x 7 sample indices x 8 series, plus timestamps, labels and forecaster counts — zero mismatches, zero nulls across 333 points. It assumes non-overlapping aggregation windows, which is how CP history is built; the tradeoff is noted in the helper's comment. - useCopyUrl accepts optional localized success/failure messages. It previously hardcoded an English success toast and dropped failures into console.error, so on the fully-translated midterms hub "Copy Link" showed English, and a denied clipboard write left the button looking broken. Both defaults are unchanged, so the other three callers behave exactly as before. Co-Authored-By: Claude Opus 5 --- front_end/messages/cs.json | 2 + front_end/messages/en.json | 2 + front_end/messages/es.json | 2 + front_end/messages/pt.json | 2 + front_end/messages/zh-TW.json | 2 + front_end/messages/zh.json | 2 + .../midterms-2026/components/insight_card.tsx | 11 ++-- .../helpers/build_control_timelines.ts | 58 +++++++++++++------ .../sections/engagement_section.tsx | 6 +- front_end/src/hooks/share.ts | 46 ++++++++++----- 10 files changed, 94 insertions(+), 39 deletions(-) diff --git a/front_end/messages/cs.json b/front_end/messages/cs.json index 5fdb918344..f491a80f53 100644 --- a/front_end/messages/cs.json +++ b/front_end/messages/cs.json @@ -2333,6 +2333,8 @@ "midtermsHubEngagementShareTitle": "Sdílejte přehled", "midtermsHubEngagementShareBody": "Myslíte, že to pomůže i ostatním sledovat americké volby v polovině mandátu 2026? Sdílejte to se svou sítí.", "midtermsHubEngagementShareOnX": "Sdílet na X", + "midtermsHubEngagementCopySuccess": "Odkaz byl zkopírován do schránky", + "midtermsHubEngagementCopyError": "Odkaz se nepodařilo zkopírovat. Zkuste to prosím znovu.", "midtermsHubEngagementTweetText": "Podívejte se na přehled predikcí amerických voleb v polovině mandátu 2026 na Metaculu", "midtermsHubEngagementNewsletterTitle": "Přihlaste se k odběru novinek", "midtermsHubEngagementNewsletterBody": "Zaregistrujte se a dostanete upozornění, když se předpovědi změní a zveřejníme nové volební analýzy.", diff --git a/front_end/messages/en.json b/front_end/messages/en.json index 81bbc94938..cc733810f2 100644 --- a/front_end/messages/en.json +++ b/front_end/messages/en.json @@ -2407,6 +2407,8 @@ "midtermsHubEngagementShareTitle": "Share the dashboard", "midtermsHubEngagementShareBody": "Think this could help others follow the 2026 midterms? Share it with your network.", "midtermsHubEngagementShareOnX": "Share on X", + "midtermsHubEngagementCopySuccess": "Link copied to your clipboard", + "midtermsHubEngagementCopyError": "Couldn't copy the link. Please try again.", "midtermsHubEngagementTweetText": "Check out the 2026 US Midterm Elections forecasting dashboard on Metaculus", "midtermsHubEngagementNewsletterTitle": "Subscribe for updates", "midtermsHubEngagementNewsletterBody": "Sign up to get notified as forecasts shift and we publish new midterms insights.", diff --git a/front_end/messages/es.json b/front_end/messages/es.json index 65c6b494fb..22b2c4cb28 100644 --- a/front_end/messages/es.json +++ b/front_end/messages/es.json @@ -2333,6 +2333,8 @@ "midtermsHubEngagementShareTitle": "Comparte el panel", "midtermsHubEngagementShareBody": "¿Crees que esto puede ayudar a otras personas a seguir las elecciones de medio mandato de 2026? Compártelo con tu red.", "midtermsHubEngagementShareOnX": "Compartir en X", + "midtermsHubEngagementCopySuccess": "Enlace copiado al portapapeles", + "midtermsHubEngagementCopyError": "No se pudo copiar el enlace. Inténtalo de nuevo.", "midtermsHubEngagementTweetText": "Echa un vistazo al panel de previsiones de las elecciones de medio mandato de EE. UU. de 2026 en Metaculus", "midtermsHubEngagementNewsletterTitle": "Suscríbete para recibir novedades", "midtermsHubEngagementNewsletterBody": "Regístrate para recibir avisos cuando cambien las previsiones y publiquemos nuevos análisis electorales.", diff --git a/front_end/messages/pt.json b/front_end/messages/pt.json index 6fa9773123..ff18417cb8 100644 --- a/front_end/messages/pt.json +++ b/front_end/messages/pt.json @@ -2331,6 +2331,8 @@ "midtermsHubEngagementShareTitle": "Compartilhe o painel", "midtermsHubEngagementShareBody": "Acha que isso pode ajudar outras pessoas a acompanhar as eleições de meio de mandato de 2026? Compartilhe com sua rede.", "midtermsHubEngagementShareOnX": "Compartilhar no X", + "midtermsHubEngagementCopySuccess": "Link copiado para a área de transferência", + "midtermsHubEngagementCopyError": "Não foi possível copiar o link. Tente novamente.", "midtermsHubEngagementTweetText": "Confira o painel de previsões das eleições de meio de mandato dos EUA de 2026 na Metaculus", "midtermsHubEngagementNewsletterTitle": "Assine para receber novidades", "midtermsHubEngagementNewsletterBody": "Cadastre-se para ser avisado quando as previsões mudarem e publicarmos novas análises eleitorais.", diff --git a/front_end/messages/zh-TW.json b/front_end/messages/zh-TW.json index 508a04399c..b387c98ddc 100644 --- a/front_end/messages/zh-TW.json +++ b/front_end/messages/zh-TW.json @@ -2330,6 +2330,8 @@ "midtermsHubEngagementShareTitle": "分享這個面板", "midtermsHubEngagementShareBody": "覺得這能幫助其他人了解 2026 年美國期中選舉嗎?分享給你的朋友。", "midtermsHubEngagementShareOnX": "在 X 上分享", + "midtermsHubEngagementCopySuccess": "連結已複製到剪貼簿", + "midtermsHubEngagementCopyError": "複製連結失敗,請重試。", "midtermsHubEngagementTweetText": "來看看 Metaculus 上的 2026 年美國期中選舉預測面板", "midtermsHubEngagementNewsletterTitle": "訂閱更新", "midtermsHubEngagementNewsletterBody": "註冊後,我們會在預測發生變化或發布新的期中選舉分析時通知您。", diff --git a/front_end/messages/zh.json b/front_end/messages/zh.json index d645601e13..51dbfea976 100644 --- a/front_end/messages/zh.json +++ b/front_end/messages/zh.json @@ -2334,6 +2334,8 @@ "midtermsHubEngagementShareTitle": "分享这个面板", "midtermsHubEngagementShareBody": "觉得这能帮助其他人了解 2026 年美国中期选举吗?分享给你的朋友。", "midtermsHubEngagementShareOnX": "在 X 上分享", + "midtermsHubEngagementCopySuccess": "链接已复制到剪贴板", + "midtermsHubEngagementCopyError": "复制链接失败,请重试。", "midtermsHubEngagementTweetText": "来看看 Metaculus 上的 2026 年美国中期选举预测面板", "midtermsHubEngagementNewsletterTitle": "订阅更新", "midtermsHubEngagementNewsletterBody": "注册后,我们会在预测发生变化或发布新的中期选举分析时通知您。", diff --git a/front_end/src/app/(main)/midterms-2026/components/insight_card.tsx b/front_end/src/app/(main)/midterms-2026/components/insight_card.tsx index 330cb95263..d9dfcbaece 100644 --- a/front_end/src/app/(main)/midterms-2026/components/insight_card.tsx +++ b/front_end/src/app/(main)/midterms-2026/components/insight_card.tsx @@ -1,9 +1,6 @@ "use client"; -import { - faArrowUpRightFromSquare, - faChartLine, -} from "@fortawesome/free-solid-svg-icons"; +import { faArrowRight, faChartLine } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import Link from "next/link"; import { useTranslations } from "next-intl"; @@ -32,8 +29,6 @@ const InsightCard: FC = ({ insight }) => { return ( = ({ insight }) => { {t("midtermsHubMetaculusUser")}
+ {/* A plain arrow, not the open-in-new-window glyph: this navigates in + the current tab, and the external-link icon would promise otherwise. */}
diff --git a/front_end/src/app/(main)/midterms-2026/helpers/build_control_timelines.ts b/front_end/src/app/(main)/midterms-2026/helpers/build_control_timelines.ts index cc3f35dc4e..9b5977b36f 100644 --- a/front_end/src/app/(main)/midterms-2026/helpers/build_control_timelines.ts +++ b/front_end/src/app/(main)/midterms-2026/helpers/build_control_timelines.ts @@ -62,16 +62,18 @@ export function buildControlTimeline( // A single point can't draw a trend. if (timestamps.length < 2) return null; + // Resolved once and shared by every series, rather than rescanning `history` + // for each timestamp of each series. + const coverage = mapTimestampsToForecasts(timestamps, history); + const choiceItems: ChoiceItem[] = series.map((config, i) => { const optionIndices = seriesIndices[i] ?? []; - const aggregationValues: (number | null)[] = []; - const aggregationForecasterCounts: number[] = []; - - for (const timestamp of timestamps) { - const entry = findCoveringForecast(history, timestamp); - aggregationValues.push(sumCenters(entry, optionIndices)); - aggregationForecasterCounts.push(entry?.forecaster_count ?? 0); - } + const aggregationValues = coverage.map((entry) => + sumCenters(entry, optionIndices) + ); + const aggregationForecasterCounts = coverage.map( + (entry) => entry?.forecaster_count ?? 0 + ); return { choice: config.label, @@ -115,15 +117,37 @@ function collectTimestamps( return [...unique].sort((a, b) => a - b); } -function findCoveringForecast( - history: MultipleChoiceAggregateForecast[], - timestamp: number -): MultipleChoiceAggregateForecast | undefined { - return history.find( - (forecast) => - forecast.start_time <= timestamp && - (forecast.end_time === null || forecast.end_time > timestamp) - ); +/** + * The forecast window covering each timestamp, resolved in a single pass. + * + * Both sequences ascend — `timestamps` by construction, `history` sorted here — + * so one monotonic cursor replaces a scan per timestamp per series. Assumes + * non-overlapping aggregation windows, which is how CP history is built; were + * they to overlap, this picks the latest-starting match rather than the first. + * A timestamp no window covers maps to `undefined`, which `sumCenters` turns + * into a null and the chart renders as a gap. + */ +function mapTimestampsToForecasts( + timestamps: number[], + history: MultipleChoiceAggregateForecast[] +): Array { + const sorted = [...history].sort((a, b) => a.start_time - b.start_time); + let cursor = 0; + + return timestamps.map((timestamp) => { + while ( + cursor + 1 < sorted.length && + (sorted[cursor + 1]?.start_time ?? Infinity) <= timestamp + ) { + cursor++; + } + const candidate = sorted[cursor]; + const covers = + !!candidate && + candidate.start_time <= timestamp && + (candidate.end_time === null || candidate.end_time > timestamp); + return covers ? candidate : undefined; + }); } /** Null if any contributing option is missing, so a gap stays a gap. */ diff --git a/front_end/src/app/(main)/midterms-2026/sections/engagement_section.tsx b/front_end/src/app/(main)/midterms-2026/sections/engagement_section.tsx index b3f1dd47b1..a3bee3ec16 100644 --- a/front_end/src/app/(main)/midterms-2026/sections/engagement_section.tsx +++ b/front_end/src/app/(main)/midterms-2026/sections/engagement_section.tsx @@ -55,7 +55,11 @@ function PartnerCard() { function ShareCard() { const t = useTranslations(); - const copyUrl = useCopyUrl({ includeHash: false }); + const copyUrl = useCopyUrl({ + includeHash: false, + successMessage: t("midtermsHubEngagementCopySuccess"), + errorMessage: t("midtermsHubEngagementCopyError"), + }); const shareOnTwitterLink = useShareOnTwitterLink( t("midtermsHubEngagementTweetText"), { includeHash: false } diff --git a/front_end/src/hooks/share.ts b/front_end/src/hooks/share.ts index 5a836ac208..b53d306caa 100644 --- a/front_end/src/hooks/share.ts +++ b/front_end/src/hooks/share.ts @@ -66,22 +66,40 @@ const useCurrentUrl = ({ includeHash = true }: CurrentUrlOptions = {}) => { }, [PUBLIC_APP_URL, hash, includeHash, pathname, searchParams]); }; -export const useCopyUrl = (options: CurrentUrlOptions = {}) => { - const url = useCurrentUrl(options); +type CopyUrlOptions = CurrentUrlOptions & { + /** Localized success toast. Defaults to the original English string. */ + successMessage?: string; + /** Localized failure toast. Omit to keep the log-only behavior. */ + errorMessage?: string; +}; + +export const useCopyUrl = ({ + successMessage = "URL is now copied to your clipboard", + errorMessage, + ...urlOptions +}: CopyUrlOptions = {}) => { + const url = useCurrentUrl(urlOptions); return useCallback(() => { - if (url) { - navigator.clipboard - .writeText(url) - .then(() => { - toast("URL is now copied to your clipboard", { - className: "dark:bg-blue-700-dark dark:text-gray-0-dark", - }); - // Optionally, show a notification to the user that the link was copied. - }) - .catch((err) => console.error("Error copying link: ", err)); - } - }, [url]); + if (!url) return; + + navigator.clipboard + .writeText(url) + .then(() => { + toast(successMessage, { + className: "dark:bg-blue-700-dark dark:text-gray-0-dark", + }); + }) + .catch((err) => { + console.error("Error copying link: ", err); + // Clipboard writes fail for real reasons — denied permission, a + // non-secure context — and a click that silently does nothing reads as a + // broken button. Callers that pass a message get told. + if (errorMessage) { + toast.error(errorMessage); + } + }); + }, [url, successMessage, errorMessage]); }; export const useMetaImageUrl = (tagName: string) => { From e7ac0e2be682b668d27c52030b5689d9bac6cd94 Mon Sep 17 00:00:00 2001 From: Atakan Seckin Date: Thu, 13 Aug 2026 12:05:12 +0200 Subject: [PATCH 5/6] Midterms: one legend, race readouts in the corner, data-fitted timelines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the map's party legend. The balance-of-power panel's timeline legend already names both parties in the same hues with live values, so the card carried two legends for one colour scheme. Dropped on the tile map too, where that panel sits directly below. Moves the race readout into the corner the legend vacated, right-aligned, and gives the Senate tab one of its own: "13 of 23 races lean Republican · 3 too close to call". summarizeRaceLeans now also counts races within 10 points of even, in the same pass so the two figures can't contradict each other; the count deliberately overlaps the lean split rather than partitioning it, since a toss-up still leans one way. Alignment is passed per call site because the summary sets a text-align class on itself, which a wrapper could not override. Fits the timelines' y-axis to the plotted range instead of a fixed 0-100%. The machinery was already wired into GroupChart, but a carve-out discarded the computed domain for binary questions; binaryYZoom opts a chart out of that and minYSpan floors the result, since generateYDomain collapses to a +/-1pp window when a series is flat and would draw noise as movement. The two party series are exact complements, so their span tracks distance from 50/50 rather than how much the forecast moved: Senate tightens to ~[0.33, 0.67], Congressional Control to ~[0, 0.55], while House at 86/14 barely changes. Values themselves are untouched - the legend still reads 86.3/13.7 and 37.3/62.7. Restores the seat-distribution median callout at every median. Zero now has its own wording and takes the label's ink; without a branch it fell through to the Republican string as "R +0 seats" in red, which is why it had been suppressed. The Senate median is zero right now, so that callout had been missing entirely. Also brings the range toggle's active state in line with the Key Drivers toggle - blue-700 inverts across themes, which is what reads as dark-on-light - and folds in the earlier uncommitted panel work: text-sm titles and legends, the reclaimed right padding behind the hidden y-axis, the floating timeframe toggle, its clipping fix, and 2M as the default range. Co-Authored-By: Claude Opus 5 --- front_end/messages/cs.json | 2 + front_end/messages/en.json | 2 + front_end/messages/es.json | 2 + front_end/messages/pt.json | 2 + front_end/messages/zh-TW.json | 2 + front_end/messages/zh.json | 2 + .../components/balance_of_power_timelines.tsx | 91 ++++++++++++++----- .../components/geographic_map.tsx | 16 ++-- .../midterms-2026/components/map_legend.tsx | 45 --------- .../components/race_lean_summary.tsx | 21 ++++- .../components/responsive_map.tsx | 22 ++++- .../components/seat_distribution_chart.tsx | 39 ++++---- .../midterms-2026/components/tile_map.tsx | 3 - .../components/timeline_range_toggle.tsx | 60 ++++++++++++ .../midterms-2026/helpers/post_utils.ts | 23 +++-- .../sections/elections_map_section.tsx | 8 +- .../src/components/charts/group_chart.tsx | 90 ++++++++++++++++-- 17 files changed, 309 insertions(+), 121 deletions(-) delete mode 100644 front_end/src/app/(main)/midterms-2026/components/map_legend.tsx create mode 100644 front_end/src/app/(main)/midterms-2026/components/timeline_range_toggle.tsx diff --git a/front_end/messages/cs.json b/front_end/messages/cs.json index f491a80f53..a8d5674f40 100644 --- a/front_end/messages/cs.json +++ b/front_end/messages/cs.json @@ -2297,6 +2297,7 @@ "midtermsHubDemocrat": "Demokraté", "midtermsHubRepublican": "Republikáni", "midtermsHubRaceLeanSummary": "V {count} z {total} soubojů vedou {party}", + "midtermsHubCloseRacesSummary": "{count, plural, one {# vyrovnaný souboj} few {# vyrovnané souboje} other {# vyrovnaných soubojů}}", "midtermsHubNotContested": "Bez voleb", "midtermsHubDemPct": "{pct}% demokraté", "midtermsHubRepPct": "{pct}% republikáni", @@ -2321,6 +2322,7 @@ "midtermsHubMedianLabel": "Medián:", "midtermsHubMedianDem": "D +{count, plural, one {# mandát} few {# mandáty} other {# mandátů}}", "midtermsHubMedianRep": "R +{count, plural, one {# mandát} few {# mandáty} other {# mandátů}}", + "midtermsHubMedianEven": "Vyrovnané rozdělení", "midtermsHubForecastUnavailable": "Předpověď nedostupná", "midtermsHubSeatAdvantageTooltip": "Náskok {count} mandátů", "midtermsHubSeatAdvantageOverTooltip": "Náskok více než {count} mandátů", diff --git a/front_end/messages/en.json b/front_end/messages/en.json index cc733810f2..a742924847 100644 --- a/front_end/messages/en.json +++ b/front_end/messages/en.json @@ -2371,6 +2371,7 @@ "midtermsHubDemocrat": "Democrat", "midtermsHubRepublican": "Republican", "midtermsHubRaceLeanSummary": "{count} of {total} races lean {party}", + "midtermsHubCloseRacesSummary": "{count} too close to call", "midtermsHubNotContested": "Not contested", "midtermsHubDemPct": "{pct}% Democrat", "midtermsHubRepPct": "{pct}% Republican", @@ -2395,6 +2396,7 @@ "midtermsHubMedianLabel": "Median:", "midtermsHubMedianDem": "D +{count, plural, one {# seat} other {# seats}}", "midtermsHubMedianRep": "R +{count, plural, one {# seat} other {# seats}}", + "midtermsHubMedianEven": "Even split", "midtermsHubForecastUnavailable": "Forecast unavailable", "midtermsHubSeatAdvantageTooltip": "{count} seat advantage", "midtermsHubSeatAdvantageOverTooltip": ">{count} seat advantage", diff --git a/front_end/messages/es.json b/front_end/messages/es.json index 22b2c4cb28..9c67698706 100644 --- a/front_end/messages/es.json +++ b/front_end/messages/es.json @@ -2297,6 +2297,7 @@ "midtermsHubDemocrat": "Demócrata", "midtermsHubRepublican": "Republicano", "midtermsHubRaceLeanSummary": "{count} de {total} contiendas favorecen al partido {party}", + "midtermsHubCloseRacesSummary": "{count, plural, one {# contienda muy reñida} other {# contiendas muy reñidas}}", "midtermsHubNotContested": "No disputado", "midtermsHubDemPct": "{pct}% Demócrata", "midtermsHubRepPct": "{pct}% Republicano", @@ -2321,6 +2322,7 @@ "midtermsHubMedianLabel": "Mediana:", "midtermsHubMedianDem": "D +{count, plural, one {# escaño} other {# escaños}}", "midtermsHubMedianRep": "R +{count, plural, one {# escaño} other {# escaños}}", + "midtermsHubMedianEven": "Reparto igualado", "midtermsHubForecastUnavailable": "Pronóstico no disponible", "midtermsHubSeatAdvantageTooltip": "{count} escaños de ventaja", "midtermsHubSeatAdvantageOverTooltip": ">{count} escaños de ventaja", diff --git a/front_end/messages/pt.json b/front_end/messages/pt.json index ff18417cb8..2a801a8848 100644 --- a/front_end/messages/pt.json +++ b/front_end/messages/pt.json @@ -2295,6 +2295,7 @@ "midtermsHubDemocrat": "Democrata", "midtermsHubRepublican": "Republicano", "midtermsHubRaceLeanSummary": "{count} de {total} disputas favorecem o partido {party}", + "midtermsHubCloseRacesSummary": "{count, plural, one {# disputa muito acirrada} other {# disputas muito acirradas}}", "midtermsHubNotContested": "Sem disputa", "midtermsHubDemPct": "{pct}% Democrata", "midtermsHubRepPct": "{pct}% Republicano", @@ -2319,6 +2320,7 @@ "midtermsHubMedianLabel": "Mediana:", "midtermsHubMedianDem": "D +{count, plural, one {# cadeira} other {# cadeiras}}", "midtermsHubMedianRep": "R +{count, plural, one {# cadeira} other {# cadeiras}}", + "midtermsHubMedianEven": "Divisão igualada", "midtermsHubForecastUnavailable": "Previsão indisponível", "midtermsHubSeatAdvantageTooltip": "{count} cadeiras de vantagem", "midtermsHubSeatAdvantageOverTooltip": ">{count} cadeiras de vantagem", diff --git a/front_end/messages/zh-TW.json b/front_end/messages/zh-TW.json index b387c98ddc..bb75317e5b 100644 --- a/front_end/messages/zh-TW.json +++ b/front_end/messages/zh-TW.json @@ -2294,6 +2294,7 @@ "midtermsHubDemocrat": "民主黨", "midtermsHubRepublican": "共和黨", "midtermsHubRaceLeanSummary": "在 {total} 場競選中,{count} 場傾向{party}", + "midtermsHubCloseRacesSummary": "{count} 場勢均力敵", "midtermsHubNotContested": "無競爭", "midtermsHubDemPct": "{pct}% 民主黨", "midtermsHubRepPct": "{pct}% 共和黨", @@ -2318,6 +2319,7 @@ "midtermsHubMedianLabel": "中位數:", "midtermsHubMedianDem": "民主黨 +{count} 席", "midtermsHubMedianRep": "共和黨 +{count} 席", + "midtermsHubMedianEven": "席次均分", "midtermsHubForecastUnavailable": "暫無預測", "midtermsHubSeatAdvantageTooltip": "領先 {count} 席", "midtermsHubSeatAdvantageOverTooltip": "領先超過 {count} 席", diff --git a/front_end/messages/zh.json b/front_end/messages/zh.json index 51dbfea976..896c23bf5e 100644 --- a/front_end/messages/zh.json +++ b/front_end/messages/zh.json @@ -2298,6 +2298,7 @@ "midtermsHubDemocrat": "民主党", "midtermsHubRepublican": "共和党", "midtermsHubRaceLeanSummary": "在 {total} 场竞选中,{count} 场倾向{party}", + "midtermsHubCloseRacesSummary": "{count} 场势均力敌", "midtermsHubNotContested": "无竞争", "midtermsHubDemPct": "{pct}% 民主党", "midtermsHubRepPct": "{pct}% 共和党", @@ -2322,6 +2323,7 @@ "midtermsHubMedianLabel": "中位数:", "midtermsHubMedianDem": "民主党 +{count} 席", "midtermsHubMedianRep": "共和党 +{count} 席", + "midtermsHubMedianEven": "席位均分", "midtermsHubForecastUnavailable": "暂无预测", "midtermsHubSeatAdvantageTooltip": "领先 {count} 席", "midtermsHubSeatAdvantageOverTooltip": "领先超过 {count} 席", diff --git a/front_end/src/app/(main)/midterms-2026/components/balance_of_power_timelines.tsx b/front_end/src/app/(main)/midterms-2026/components/balance_of_power_timelines.tsx index c469987575..de056b95a9 100644 --- a/front_end/src/app/(main)/midterms-2026/components/balance_of_power_timelines.tsx +++ b/front_end/src/app/(main)/midterms-2026/components/balance_of_power_timelines.tsx @@ -1,7 +1,7 @@ "use client"; import { useTranslations } from "next-intl"; -import { FC, useMemo, useState } from "react"; +import { FC, ReactNode, useMemo, useState } from "react"; import GroupChart from "@/components/charts/group_chart"; import { MULTIPLE_CHOICE_COLOR_SCALE } from "@/constants/colors"; @@ -15,6 +15,7 @@ import { ThemeColor } from "@/types/theme"; import { MIDTERMS_COLORS } from "../constants"; import { CONGRESS_OUTCOME_LABELS } from "../data"; +import TimelineRangeToggle from "./timeline_range_toggle"; import { buildControlTimeline, ControlTimeline, @@ -56,6 +57,13 @@ const BalanceOfPowerTimelines: FC = ({ post }) => { const question = post?.question as | QuestionWithMultipleChoiceForecasts | undefined; + // One timeframe for all three charts — they answer the same question about the + // same window, so letting them drift apart would invite false comparisons. + // Opens on two months: the full history reaches back to Jan 2025 and its early + // flat stretch squashes the recent movement. ALL is a click away. + const [zoom, setZoom] = useState( + TimelineChartZoomOption.TwoMonths + ); const demLabel = t("midtermsHubPartyDemocrats"); const repLabel = t("midtermsHubPartyRepublicans"); @@ -114,26 +122,44 @@ const BalanceOfPowerTimelines: FC = ({ post }) => { }, [question, demLabel, repLabel, t]); const unavailableLabel = t("midtermsHubTimelineUnavailable"); + const rangeToggle = ; return ( - // Even spacing between the three charts, flush to the panel's own padding at - // the outer edges. -
- - - +
+ {/* md+: straddle the card's top edge at the panel's right. The negative + offset cancels the column's own padding so the pill's centre lands on + the border, and it tracks the padding step at lg. */} + {/* flex, not block: a block wrapper around the inline-flex pill picks up + ~8px of baseline descender space, which would offset the centring. */} +
+ {rangeToggle} +
+ {/* Spacing lives on its own wrapper: as a sibling of the absolute toggle, + the child selectors below can't catch it — previously it picked up py-2 + and stole :first-child from the House block. */} +
+ {rangeToggle}
} + /> + + +
); }; @@ -142,16 +168,22 @@ const TimelineBlock: FC<{ title: string; timeline: ControlTimeline | null; unavailableLabel: string; -}> = ({ title, timeline, unavailableLabel }) => { + zoom: TimelineChartZoomOption; + /** Optional control rendered on the title's row, right-aligned. */ + titleAccessory?: ReactNode; +}> = ({ title, timeline, unavailableLabel, zoom, titleAccessory }) => { // Null = not hovering, so the legend shows the latest value. GroupChart reports // the hovered timestamp here and we look each series up at that point. const [cursorTimestamp, setCursorTimestamp] = useState(null); return (
-

- {title} -

+
+

+ {title} +

+ {titleAccessory} +
{timeline ? (
setCursorTimestamp(null)}> = ({ races, tabsSlot, summarySlot }) => { ref={containerRef} className="geo-map-container relative h-full w-full" > - {/* Header overlay: tabs | summary | legend. Absolutely positioned as one - row so a wrapping summary grows over the map instead of pushing it - down, while the gap keeps it clear of both neighbors. The paddings - reproduce the standalone positions these two used to have. */} + {/* Header overlay: tabs on the left, summary on the right. Absolutely + positioned as one row so a wrapping summary grows over the map instead + of pushing it down, while the gap keeps it clear of the tabs. + The summary occupies the corner the party legend used to; its own + alignment comes from the className the caller passes, since this cell + can't override a class the summary sets on itself. */} {/* Flush to the container: the map column's own padding provides the inset, - so the legend clears the dividing rule by the same 40px as everything + so the summary clears the dividing rule by the same 40px as everything else. */}
{tabsSlot &&
{tabsSlot}
}
{summarySlot}
-
- -
= ({ className }) => { - const t = useTranslations(); - - return ( -
- - - {t("midtermsHubDemocrat")} - - - - {t("midtermsHubRepublican")} - -
- ); -}; - -export default MapLegend; diff --git a/front_end/src/app/(main)/midterms-2026/components/race_lean_summary.tsx b/front_end/src/app/(main)/midterms-2026/components/race_lean_summary.tsx index b11302f4d4..b8b27e3f17 100644 --- a/front_end/src/app/(main)/midterms-2026/components/race_lean_summary.tsx +++ b/front_end/src/app/(main)/midterms-2026/components/race_lean_summary.tsx @@ -14,10 +14,15 @@ import { useIsDark } from "../helpers/use_is_dark"; type Props = { races: SenateRaceWithQuestion[]; + /** Appends a toss-up count to the lean sentence. The Senate map uses it: with + * a third of the chamber up, which seats are still in play says more than the + * lean split alone. Omitted on the Governor map, where the count would be + * noise next to 19 mostly-settled races. */ + withCloseRaces?: boolean; className?: string; }; -const RaceLeanSummary: FC = ({ races, className }) => { +const RaceLeanSummary: FC = ({ races, withCloseRaces, className }) => { const t = useTranslations(); const isDark = useIsDark(); const leans = summarizeRaceLeans(races); @@ -55,6 +60,20 @@ const RaceLeanSummary: FC = ({ races, className }) => { ), })} + {/* The separator lives here rather than in either message so translators + don't inherit punctuation that only makes sense when both clauses are + present. Dropped entirely when nothing is close. */} + {withCloseRaces && leans.close > 0 && ( + <> + + · + + {t("midtermsHubCloseRacesSummary", { count: leans.close })} + + )}

); }; diff --git a/front_end/src/app/(main)/midterms-2026/components/responsive_map.tsx b/front_end/src/app/(main)/midterms-2026/components/responsive_map.tsx index 36b9c989e4..8dbd51dab9 100644 --- a/front_end/src/app/(main)/midterms-2026/components/responsive_map.tsx +++ b/front_end/src/app/(main)/midterms-2026/components/responsive_map.tsx @@ -16,9 +16,13 @@ type Props = { const ResponsiveMap: FC = ({ senateRaces, governorRaces }) => { const [view, setView] = useState("senate"); const races = view === "senate" ? senateRaces : governorRaces; - // Governor-only: the Senate map deliberately has no summary line. - const summarySlot = - view === "governor" ? : null; + // Built per branch rather than shared: alignment differs between the two maps, + // and RaceLeanSummary sets a text-align class on itself, so only a className + // passed in can override it — a wrapper's class would lose. + const summaryProps = { + races, + withCloseRaces: view === "senate", + }; return ( <> @@ -29,7 +33,11 @@ const ResponsiveMap: FC = ({ senateRaces, governorRaces }) => { } - summarySlot={summarySlot} + // Right-aligned: it sits in the corner the party legend used to hold, + // so it wraps leftwards into empty space instead of towards the tabs. + summarySlot={ + + } />
@@ -37,7 +45,11 @@ const ResponsiveMap: FC = ({ senateRaces, governorRaces }) => {
- {summarySlot &&
{summarySlot}
} + {/* empty:hidden so the gap collapses when the summary renders nothing + — it bails to null before any race is forecast. */} +
+ +
diff --git a/front_end/src/app/(main)/midterms-2026/components/seat_distribution_chart.tsx b/front_end/src/app/(main)/midterms-2026/components/seat_distribution_chart.tsx index aa219d52c9..ab16c8028b 100644 --- a/front_end/src/app/(main)/midterms-2026/components/seat_distribution_chart.tsx +++ b/front_end/src/app/(main)/midterms-2026/components/seat_distribution_chart.tsx @@ -368,28 +368,33 @@ const SeatDistributionChart: FC = ({ const medianInk = isDark ? MEDIAN_INK_DARK : MEDIAN_INK_LIGHT; const medianLabelFill = isDark ? "#262f38" : "#ffffff"; const medianSeats = Math.round(quartileXs.median); - // A median of exactly zero gets no callout at all: the EVEN bin already owns - // that spot, so a marker there would only restate it — and labelling the two - // separately at the same x invites reading them as the same claim (the EVEN - // bar is P(advantage = 0); the median is where the CDF crosses 50%). Returns - // on its own as soon as the median moves off zero. - const showMedian = medianSeats !== 0; + // Zero needs its own wording: it belongs to neither party, and without a branch + // it falls through to the Republican string as "R +0 seats" in red. It lands on + // the EVEN bin, which does state a different thing (that bar is + // P(advantage = 0); the median is where the CDF crosses 50%) — the callout is + // shown at every median regardless. const medianLabelText = t("midtermsHubMedianLabel"); const medianValueText = - medianSeats < 0 - ? t("midtermsHubMedianDem", { count: Math.abs(medianSeats) }) - : t("midtermsHubMedianRep", { count: medianSeats }); + medianSeats === 0 + ? t("midtermsHubMedianEven") + : medianSeats < 0 + ? t("midtermsHubMedianDem", { count: Math.abs(medianSeats) }) + : t("midtermsHubMedianRep", { count: medianSeats }); // Keyed off the flyout's own background, not the page: the light page's black // flyout needs the *Dark* swatches (the pair designed to read on dark // surfaces), and the dark page's white flyout needs the darker Border pair. + // Zero takes the label's own ink — party colors would assert a lean that isn't + // there. const medianValueFill = - medianSeats < 0 - ? isDark - ? MIDTERMS_COLORS.demBorder - : MIDTERMS_COLORS.demPrimaryDark - : isDark - ? MIDTERMS_COLORS.repBorder - : MIDTERMS_COLORS.repPrimaryDark; + medianSeats === 0 + ? medianLabelFill + : medianSeats < 0 + ? isDark + ? MIDTERMS_COLORS.demBorder + : MIDTERMS_COLORS.demPrimaryDark + : isDark + ? MIDTERMS_COLORS.repBorder + : MIDTERMS_COLORS.repPrimaryDark; const BAR_FILL_OPACITY = 0.7; const BAR_FILL_OPACITY_HOVER = 1; @@ -873,7 +878,7 @@ const SeatDistributionChart: FC = ({ always-on flyout. Rendered last so it paints above the bars, and unmounted entirely while a bin is hovered so it never stacks with the hover tooltip. */} - {showMedian && !isBinHovered && ( + {!isBinHovered && ( = ({ races }) => { /> )} - -
); }; diff --git a/front_end/src/app/(main)/midterms-2026/components/timeline_range_toggle.tsx b/front_end/src/app/(main)/midterms-2026/components/timeline_range_toggle.tsx new file mode 100644 index 0000000000..e522e57f7a --- /dev/null +++ b/front_end/src/app/(main)/midterms-2026/components/timeline_range_toggle.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { FC } from "react"; + +import { TimelineChartZoomOption } from "@/types/charts"; +import { getChartZoomOptions } from "@/utils/charts/helpers"; +import cn from "@/utils/core/cn"; + +type Props = { + value: TimelineChartZoomOption; + onChange: (value: TimelineChartZoomOption) => void; + className?: string; +}; + +/** + * Timeframe picker for the balance-of-power timelines. Same options and values as + * the zoom picker built into ChartContainer — sourced from the same + * `getChartZoomOptions` so the two can't drift — but fully rounded and hoisted + * out of the chart so a single control drives all three at once. + */ +const TimelineRangeToggle: FC = ({ value, onChange, className }) => { + const options = getChartZoomOptions(); + + return ( +
+ {options.map((option) => { + const active = option.value === value; + return ( + + ); + })} +
+ ); +}; + +export default TimelineRangeToggle; diff --git a/front_end/src/app/(main)/midterms-2026/helpers/post_utils.ts b/front_end/src/app/(main)/midterms-2026/helpers/post_utils.ts index 8d77a1a53b..cc3201849a 100644 --- a/front_end/src/app/(main)/midterms-2026/helpers/post_utils.ts +++ b/front_end/src/app/(main)/midterms-2026/helpers/post_utils.ts @@ -95,23 +95,32 @@ export function getDemWinPct( return Math.round(prob * 100); } +/** Half-width of the toss-up band, in points either side of 50. */ +const CLOSE_RACE_MARGIN = 10; + /** - * Counts races by favored side. The `>= 50` split matches the one - * `state_tooltip.tsx` uses, so a summary can never disagree with a tooltip. - * Races with no forecast are excluded from every count, and `total` is the sum - * of the two sides rather than `races.length` so the numbers always reconcile. - * Null when nothing is forecast yet. + * Counts races by favored side, plus how many are close enough to be in play. + * The `>= 50` split matches the one `state_tooltip.tsx` uses, so a summary can + * never disagree with a tooltip. Races with no forecast are excluded from every + * count, and `total` is the sum of the two sides rather than `races.length` so + * the numbers always reconcile. Null when nothing is forecast yet. + * + * `close` deliberately overlaps `dem` and `rep` rather than partitioning them: a + * toss-up still leans one way, and pulling it out of its side's count would make + * the two figures contradict each other when read in one sentence. */ export function summarizeRaceLeans( races: SenateRaceWithQuestion[] -): { dem: number; rep: number; total: number } | null { +): { dem: number; rep: number; close: number; total: number } | null { let dem = 0; let rep = 0; + let close = 0; for (const race of races) { if (race.demWinPct == null) continue; if (race.demWinPct >= 50) dem += 1; else rep += 1; + if (Math.abs(race.demWinPct - 50) <= CLOSE_RACE_MARGIN) close += 1; } const total = dem + rep; - return total > 0 ? { dem, rep, total } : null; + return total > 0 ? { dem, rep, close, total } : null; } diff --git a/front_end/src/app/(main)/midterms-2026/sections/elections_map_section.tsx b/front_end/src/app/(main)/midterms-2026/sections/elections_map_section.tsx index d43681f2b2..09ebcfab15 100644 --- a/front_end/src/app/(main)/midterms-2026/sections/elections_map_section.tsx +++ b/front_end/src/app/(main)/midterms-2026/sections/elections_map_section.tsx @@ -16,13 +16,17 @@ export default async function ElectionsMapSection() { fetchChamberData(), ]); + // The card carries no overflow clip: that only ever existed to contain the + // geographic map's paths, and on the card it also cut off the balance-of-power + // panel's range toggle, which is meant to straddle the top edge. The clip now + // sits on the map column alone. return ( - +
{/* Map column. Padding matches the panel's, so the map is inset the same 40px from the dividing rule as the panel's content is — and the tabs, summary and legend overlay inherit it instead of setting their own. */} -
+
void; withZoomPicker?: boolean; height?: number; yLabel?: string; @@ -125,9 +130,22 @@ type Props = { withHighlightEndpoint?: boolean; headerLeft?: ReactNode; yDomainOptions?: TimelineYDomainOptions; + /** Let binary questions use the computed zoomed y-domain instead of the fixed + * [0, 1]. Off by default: platform-wide, a binary timeline is read against a + * full probability axis, and shrinking it silently would overstate movement. + * Opt in where the values are surfaced numerically elsewhere. */ + binaryYZoom?: boolean; + /** Floor on the zoomed domain's height, in probability units. Guards the + * degenerate case: `generateYDomain` falls back to a ±1pp window when the + * visible span is zero, which would draw sampling noise as a swing. */ + minYSpan?: number; }; const BOTTOM_PADDING = 20; +// Endpoint-dot clearance used in place of the y-label allowance when hideYAxis is +// set — matches the SCATTER_POINT_PADDING that getAxisRightPadding adds for the +// same dot, plus a pixel for its stroke. +const HIDDEN_Y_AXIS_RIGHT_PADDING = 6; const POINT_SIZE = 9; const USER_POINT_SIZE = 6; const USER_POINT_STROKE = CHART_STROKE_WIDTH.userPoint; @@ -138,6 +156,8 @@ const GroupChart: FC = ({ actualCloseTime, choiceItems, defaultZoom = TimelineChartZoomOption.All, + zoom: controlledZoom, + onZoomChange, withZoomPicker = false, height = 150, yLabel, @@ -173,6 +193,8 @@ const GroupChart: FC = ({ withHighlightEndpoint = false, headerLeft, yDomainOptions, + binaryYZoom = false, + minYSpan, }) => { const t = useTranslations(); const { @@ -200,7 +222,16 @@ const GroupChart: FC = ({ ); const [isCursorActive, setIsCursorActive] = useState(false); - const [zoom, setZoom] = useState(defaultZoom); + // Controlled/uncontrolled: an external picker can own the zoom, otherwise the + // chart keeps its own. Internal state still tracks so nothing jumps if a + // caller stops controlling it mid-life. + const [internalZoom, setInternalZoom] = + useState(defaultZoom); + const zoom = controlledZoom ?? internalZoom; + const handleZoomChange = (next: TimelineChartZoomOption) => { + setInternalZoom(next); + onZoomChange?.(next); + }; const [yDomainSource, setYDomainSource] = useState( yDomainOptions?.source ?? DEFAULT_TIMELINE_Y_DOMAIN_OPTIONS.source ); @@ -232,6 +263,8 @@ const GroupChart: FC = ({ ...yDomainOptions, source: yDomainSource, }, + binaryYZoom, + minYSpan, }), [ timestamps, @@ -250,6 +283,8 @@ const GroupChart: FC = ({ forFeedPage, yDomainOptions, yDomainSource, + binaryYZoom, + minYSpan, ] ); const [localCursorTimestamp, setLocalCursorTimestamp] = useState< @@ -288,9 +323,11 @@ const GroupChart: FC = ({ return getAxisRightPadding(yScale, tickLabelFontSize as number, yLabel); }, [yScale, tickLabelFontSize, yLabel]); const maxRightPadding = useMemo(() => { - // With the labels hidden there's nothing to reserve width for, but keep the - // floor so the last x-axis tick still has room. - if (hideYAxis) return MIN_RIGHT_PADDING; + // MIN_RIGHT_PADDING (35px) exists to fit y-axis tick labels. With them hidden + // that space is dead, and it reads as lopsided padding against a container + // whose own padding is symmetric. Keep only what the line's endpoint dot + // needs to sit fully inside the plot. + if (hideYAxis) return HIDDEN_Y_AXIS_RIGHT_PADDING; return Math.max(rightPadding, MIN_RIGHT_PADDING); }, [hideYAxis, rightPadding, MIN_RIGHT_PADDING]); const chartPadding = useMemo( @@ -399,7 +436,7 @@ const GroupChart: FC = ({ ref={chartContainerRef} height={height} zoom={withZoomPicker ? zoom : undefined} - onZoomChange={setZoom} + onZoomChange={handleZoomChange} yDomainSource={ withZoomPicker && questionType !== QuestionType.Binary ? yDomainSource @@ -973,6 +1010,8 @@ function buildChartData({ forFeedPage, isEmbedded, yDomainOptions, + binaryYZoom, + minYSpan, }: { timestamps: number[]; actualCloseTime?: number | null; @@ -990,6 +1029,8 @@ function buildChartData({ forFeedPage?: boolean; isEmbedded?: boolean; yDomainOptions?: TimelineYDomainOptions; + binaryYZoom?: boolean; + minYSpan?: number; }): ChartData { const closeTimes = choiceItems .map(({ closeTime }) => closeTime) @@ -1319,10 +1360,12 @@ function buildChartData({ paddingRatio: effectiveYDomainOptions.paddingRatio, }); const { originalYDomain, tickCoverageDomain } = generatedYDomain; + // Binary questions default to the full [0, 1] axis regardless of how little the + // forecast moves; `binaryYZoom` opts a chart out of that. const zoomedYDomain = - questionType === QuestionType.Binary + questionType === QuestionType.Binary && !binaryYZoom ? originalYDomain - : generatedYDomain.zoomedYDomain; + : expandDomainToMinSpan(generatedYDomain.zoomedYDomain, minYSpan); const yScale = generateScale({ displayType: questionType, @@ -1342,6 +1385,39 @@ function buildChartData({ return { xScale, yScale: visibleYScale, graphs, xDomain, yDomain }; } +/** + * Widens a y-domain about its midpoint until it spans at least `minSpan`, so a + * barely-moving series can't zoom in far enough to render noise as movement. + * A no-op without `minSpan`, which is how every caller that doesn't opt in + * leaves it. + * + * When the widened window would run past 0 or 1 it slides back inside rather than + * being clipped: a clipped domain would still be shorter than `minSpan` and so + * still over-zoomed, which is the thing being guarded against. + */ +function expandDomainToMinSpan( + domain: Tuple, + minSpan?: number +): Tuple { + if (isNil(minSpan) || minSpan <= 0) return domain; + const [min, max] = domain; + if (max - min >= minSpan) return domain; + + const target = Math.min(minSpan, 1); + const mid = (min + max) / 2; + let lower = mid - target / 2; + let upper = mid + target / 2; + if (lower < 0) { + upper -= lower; + lower = 0; + } + if (upper > 1) { + lower -= upper - 1; + upper = 1; + } + return [Math.max(0, lower), Math.min(1, upper)]; +} + // Define a custom "X" symbol function type SymbolProps = PointProps & { size?: number; strokeWidth?: number }; const PredictionSymbol: React.FC = (props) => { From 689d22beb8e218db1f193b635bc2013d5f0a35de Mon Sep 17 00:00:00 2001 From: Atakan Seckin Date: Thu, 13 Aug 2026 13:28:37 +0200 Subject: [PATCH 6/6] Charts and share: mirror controlled zoom, guard the clipboard API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GroupChart's internal zoom state only ever changed through the chart's own picker, so a parent that drives `zoom` from elsewhere never reached it. The midterms panel does exactly that: internal state sat at `defaultZoom` while the chart displayed 2M, and releasing control would have snapped the range back. A useEffect now mirrors the controlled value, matching the pattern the neighbouring `yDomainSource` state already uses. Uncontrolled behaviour and the onZoomChange callback are unchanged. `navigator.clipboard` is undefined outside a secure context, so reading `.writeText` off it threw synchronously, where the promise's catch could not see it — the click ended in an uncaught error and no toast, which reads as a broken button. useCopyUrl now checks for the API first and reports through the existing localized error path. The success and async-rejection paths are untouched. Co-Authored-By: Claude Opus 5 --- front_end/src/components/charts/group_chart.tsx | 12 ++++++++++-- front_end/src/hooks/share.ts | 12 ++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/front_end/src/components/charts/group_chart.tsx b/front_end/src/components/charts/group_chart.tsx index 0723b883ef..1bc5d4fa24 100644 --- a/front_end/src/components/charts/group_chart.tsx +++ b/front_end/src/components/charts/group_chart.tsx @@ -223,10 +223,18 @@ const GroupChart: FC = ({ const [isCursorActive, setIsCursorActive] = useState(false); // Controlled/uncontrolled: an external picker can own the zoom, otherwise the - // chart keeps its own. Internal state still tracks so nothing jumps if a - // caller stops controlling it mid-life. + // chart keeps its own. const [internalZoom, setInternalZoom] = useState(defaultZoom); + // Mirror the controlled value so nothing jumps if a caller stops controlling + // mid-life. Without this, internal state only saw changes made through the + // chart's own picker, so a parent that drives zoom from elsewhere would leave + // it at `defaultZoom` and the chart would snap back to that on release. + useEffect(() => { + if (!isNil(controlledZoom)) { + setInternalZoom(controlledZoom); + } + }, [controlledZoom]); const zoom = controlledZoom ?? internalZoom; const handleZoomChange = (next: TimelineChartZoomOption) => { setInternalZoom(next); diff --git a/front_end/src/hooks/share.ts b/front_end/src/hooks/share.ts index b53d306caa..63a31f7cdb 100644 --- a/front_end/src/hooks/share.ts +++ b/front_end/src/hooks/share.ts @@ -83,6 +83,18 @@ export const useCopyUrl = ({ return useCallback(() => { if (!url) return; + // `navigator.clipboard` is undefined outside a secure context — plain http + // on a LAN address, for instance — so reading `.writeText` off it throws + // synchronously, where the promise's catch below cannot see it and the + // click ends in an uncaught error instead of a toast. + if (!navigator.clipboard?.writeText) { + console.error("Error copying link: clipboard unavailable"); + if (errorMessage) { + toast.error(errorMessage); + } + return; + } + navigator.clipboard .writeText(url) .then(() => {