diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 6fe38c160..31afbdcaa 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -357,15 +357,15 @@ main { /* Road incident card: what it is and where, when, what it does to * traffic, the source's own words, and a quiet credit line. */ + /* The card is the popup's only scroll area, reached only when an opened + * text or a stack of conditions outgrows the screen. */ .omx-incident { width: 268px; max-width: 100%; - font-size: 13px; - line-height: 1.4; - } - .omx-incident--stack { max-height: 60vh; overflow-y: auto; + font-size: 13px; + line-height: 1.4; } .omx-incident__count { padding-right: 26px; @@ -472,11 +472,36 @@ main { } .omx-incident__text { margin-top: 10px; - max-height: 132px; - overflow-y: auto; white-space: pre-line; + } + .omx-incident__body { opacity: 0.85; } + /* A long text shows its first four lines, fading out, until opened. */ + .omx-incident__text--long:not(:has(.omx-incident__more[open])) .omx-incident__body { + max-height: calc(4 * 1.4em); + overflow: hidden; + mask-image: linear-gradient(to bottom, #000 55%, transparent); + } + .omx-incident__more summary { + display: inline-block; + margin-top: 2px; + font-weight: 600; + color: var(--omx-brand); + cursor: pointer; + list-style: none; + } + .omx-incident__more summary::-webkit-details-marker { + display: none; + } + .omx-incident__more summary:hover, + .omx-incident__more summary:focus-visible { + text-decoration: underline; + } + .omx-incident__more[open] .omx-incident__show, + .omx-incident__more:not([open]) .omx-incident__hide { + display: none; + } .omx-incident__text p { margin: 0; } diff --git a/docs/docs/features/map-layers.md b/docs/docs/features/map-layers.md index ffbf140b0..60a6d4f0e 100644 --- a/docs/docs/features/map-layers.md +++ b/docs/docs/features/map-layers.md @@ -123,7 +123,8 @@ incident displays a localized card: what it is and the road and direction it affects, how much it affects traffic, until when it holds (or when it starts, including recurring schedule windows in the source's local timezone), what it does to traffic (closed roads and lanes, vehicle limits, delays, detours), the -source's own description, and one credit line per source with when it was last +source's own description (a long one shows its first lines and opens in place with +"Show more"), and one credit line per source with when it was last updated. Overlapping markers are automatically grouped into a single scrollable popup (ordered most-severe first) so all incidents at the same spot stay reachable, and linear events (such as segment closures) render as line segments directly on the map. diff --git a/integrations/road-conditions/__tests__/popup.test.ts b/integrations/road-conditions/__tests__/popup.test.ts index 9f4e1dd70..a45dae3eb 100644 --- a/integrations/road-conditions/__tests__/popup.test.ts +++ b/integrations/road-conditions/__tests__/popup.test.ts @@ -151,6 +151,22 @@ describe("incident card content", () => { ).not.toHaveProperty("headline"); }); + it("prints a short text in full and opens a long one in place, never in a scroll area", () => { + const short = render([situation({ description: text("Erneuerung an der Decke") })]).html; + expect(short).toContain("Erneuerung an der Decke"); + expect(short).not.toContain("omx-incident__more"); + + const long = "Angespannte Verkehrslage zwischen Duisburg-Beeck und Oberhausen-West. ".repeat(4); + const shown = card([situation({ headline: text("A42 | Duisburg"), description: text(long) })]); + expect(shown?.more).toEqual({ show: "panel.showMore", hide: "panel.showLess" }); + const html = render([situation({ description: text(long) })]).html; + // The whole text is in the card; the disclosure only lifts its fade. + expect(html).toContain(long.trim()); + expect(html).toContain('
'); + expect(html).toContain("panel.showMore"); + expect(html).toContain("panel.showLess"); + }); + it("shows the headline, description and road names in the reader's language", () => { const event = situation({ headline: [ diff --git a/integrations/road-conditions/popup.tsx b/integrations/road-conditions/popup.tsx index b4eb63e96..4ca98eef9 100644 --- a/integrations/road-conditions/popup.tsx +++ b/integrations/road-conditions/popup.tsx @@ -67,6 +67,8 @@ export interface IncidentCard { /** The source's own headline, where it says more than the title. */ headline?: string; description?: string; + /** Present when the source's text runs longer than the card shows at first. */ + more?: { show: string; hide: string }; warning?: string; /** The captured credit of every source shown, as the map credits receive them. */ credits: Attribution[]; @@ -414,6 +416,10 @@ export function buildIncidentCard( const formatUpdated = input.formatRelative ?? input.formatDateTime; const sameText = (a?: string, b?: string) => Boolean(a && b && a.toLocaleLowerCase() === b.toLocaleLowerCase()); + const shownHeadline = + headline && !sameText(headline, title) && !sameText(headline, description) + ? headline + : undefined; return { severity, glyph: markerGlyph(representative), @@ -424,10 +430,11 @@ export function buildIncidentCard( ...(events.every(isUnconfirmedCrowd) ? { unconfirmed: translate("report.unconfirmed") } : {}), when: whenText(groupValidity(events, representative), future, input), impacts, - ...(headline && !sameText(headline, title) && !sameText(headline, description) - ? { headline } - : {}), + ...(shownHeadline ? { headline: shownHeadline } : {}), ...(description ? { description } : {}), + ...(estimatedLines([shownHeadline, description]) > TEXT_LINES_SHOWN + 1 + ? { more: { show: translate("panel.showMore"), hide: translate("panel.showLess") } } + : {}), ...(input.needsRefresh ? { warning: translate("freshness.needsRefresh") } : expired @@ -440,6 +447,18 @@ export function buildIncidentCard( }; } +/** The lines of the source's text a card shows before "Show more". */ +const TEXT_LINES_SHOWN = 4; +/** Roughly the characters of one line of card text. */ +const CHARS_PER_LINE = 38; + +/** About how many lines the texts take in a card, each paragraph and line break apart. */ +function estimatedLines(texts: readonly (string | undefined)[]): number { + return texts + .flatMap((text) => (text ? text.split("\n") : [])) + .reduce((lines, line) => lines + Math.max(1, Math.ceil(line.length / CHARS_PER_LINE)), 0); +} + function glyphSvg(glyph: string): string { const path = GLYPHS[glyph] ?? GLYPHS.other; return ``; @@ -478,12 +497,18 @@ export function renderIncidentCard(card: IncidentCard, options: PopupCreditsOpti .map((line) => `
  • ${escapeHtml(line)}
  • `) .join("")}` : ""; - const text = - card.headline || card.description - ? `
    ${ - card.headline ? `

    ${escapeHtml(card.headline)}

    ` : "" - }${card.description ? `

    ${escapeHtml(card.description)}

    ` : ""}
    ` - : ""; + const paragraphs = `${ + card.headline ? `

    ${escapeHtml(card.headline)}

    ` : "" + }${card.description ? `

    ${escapeHtml(card.description)}

    ` : ""}`; + // A long text shows its first lines and opens in place, so the card never + // holds a second scroll area. + const text = !paragraphs + ? "" + : card.more + ? `
    ${paragraphs}
    ${escapeHtml( + card.more.show, + )}${escapeHtml(card.more.hide)}
    ` + : `
    ${paragraphs}
    `; const warning = card.warning ? `
    ${escapeHtml(card.warning)}
    ` : ""; diff --git a/packages/i18n/locales/de.json b/packages/i18n/locales/de.json index 18b10abee..269a14e99 100644 --- a/packages/i18n/locales/de.json +++ b/packages/i18n/locales/de.json @@ -594,7 +594,9 @@ }, "panel": { "conditionsHere": "{count, plural, one {# Meldung hier} other {# Meldungen hier}}", - "updated": "Aktualisiert {time}" + "updated": "Aktualisiert {time}", + "showMore": "Mehr anzeigen", + "showLess": "Weniger anzeigen" }, "binding": { "exact": "Exakt", diff --git a/packages/i18n/locales/en.json b/packages/i18n/locales/en.json index 68d41021d..3415186c6 100644 --- a/packages/i18n/locales/en.json +++ b/packages/i18n/locales/en.json @@ -594,7 +594,9 @@ }, "panel": { "conditionsHere": "{count, plural, one {# condition here} other {# conditions here}}", - "updated": "Updated {time}" + "updated": "Updated {time}", + "showMore": "Show more", + "showLess": "Show less" }, "binding": { "exact": "Exact",