From 4c85515e5017d9b4c6ad00510f034519f6199d6c Mon Sep 17 00:00:00 2001 From: mAi Date: Mon, 18 May 2026 17:40:43 +0200 Subject: [PATCH] feat(t-paliad-211): timeline shape adds zoom toolbar and clamped lane labels shape-timeline-cv now wraps the chart host with a toolbar carrying +/- zoom buttons and 1y/2y/all chips. Active zoom persists in the URL as ?tl_zoom=1y|2y|all (URL > render-spec range_preset > "1y" default), so saved views still control the initial zoom but per-session navigation is deep-linkable. shape-timeline-chart paints lane labels inside a foreignObject containing an HTML
with overflow:hidden + text-overflow:ellipsis + a title attribute carrying the full text. Long project names no longer bleed across the chart canvas; hover reveals the full label. i18n: views.timeline.zoom.{label,in,out,1y,2y,all} (DE+EN). --- frontend/src/client/i18n.ts | 12 ++ .../src/client/views/shape-timeline-chart.ts | 23 +++- .../src/client/views/shape-timeline-cv.ts | 117 +++++++++++++++++- frontend/src/i18n-keys.ts | 6 + frontend/src/styles/global.css | 48 ++++++- 5 files changed, 197 insertions(+), 9 deletions(-) diff --git a/frontend/src/client/i18n.ts b/frontend/src/client/i18n.ts index acd6e68..88ca9a5 100644 --- a/frontend/src/client/i18n.ts +++ b/frontend/src/client/i18n.ts @@ -2251,6 +2251,12 @@ const translations: Record> = { "views.shape.calendar": "Kalender", "views.shape.timeline": "Timeline", "views.timeline.caveat.body": "Custom Views zeigen nur eingetretene Ereignisse. Für prognostizierte Fristen das Projekt-Chart öffnen.", + "views.timeline.zoom.label": "Zoom", + "views.timeline.zoom.in": "Heranzoomen", + "views.timeline.zoom.out": "Herauszoomen", + "views.timeline.zoom.1y": "±1 J.", + "views.timeline.zoom.2y": "±2 J.", + "views.timeline.zoom.all": "Alles", "views.save_as": "Als Ansicht speichern", "views.action.edit": "Bearbeiten", "views.empty.title": "Keine Einträge gefunden.", @@ -4816,6 +4822,12 @@ const translations: Record> = { "views.shape.calendar": "Calendar", "views.shape.timeline": "Timeline", "views.timeline.caveat.body": "Custom Views show actual events only. Open the project's chart for projected rules.", + "views.timeline.zoom.label": "Zoom", + "views.timeline.zoom.in": "Zoom in", + "views.timeline.zoom.out": "Zoom out", + "views.timeline.zoom.1y": "±1 yr", + "views.timeline.zoom.2y": "±2 yr", + "views.timeline.zoom.all": "All", "views.save_as": "Save as view", "views.action.edit": "Edit", "views.empty.title": "No matches found.", diff --git a/frontend/src/client/views/shape-timeline-chart.ts b/frontend/src/client/views/shape-timeline-chart.ts index 98835ab..0741e63 100644 --- a/frontend/src/client/views/shape-timeline-chart.ts +++ b/frontend/src/client/views/shape-timeline-chart.ts @@ -467,6 +467,11 @@ export function paint( } // Lane separators — horizontal lines between rows + labels in the gutter. + // Labels live inside so HTML/CSS handles ellipsis + + // tooltip cleanly. SVG has no auto-clipping and long titles + // would bleed into the chart canvas (t-paliad-211). + const labelPadding = 8; + const labelMaxWidth = Math.max(0, chart.viewport.laneLabelWidth - labelPadding * 2); for (let i = 0; i < chart.laneRows.length; i++) { const row = chart.laneRows[i]; if (i > 0) { @@ -479,13 +484,19 @@ export function paint( })); } if (row.label) { - const labelEl = svg("text", { - class: "chart-lane-label", - x: 8, - y: row.y + row.height / 2 + 4, + const fo = svg("foreignObject", { + class: "chart-lane-label-fo", + x: labelPadding, + y: row.y, + width: labelMaxWidth, + height: row.height, }); - labelEl.textContent = row.label; - gGrid.appendChild(labelEl); + const div = document.createElement("div"); + div.className = "chart-lane-label"; + div.textContent = row.label; + div.title = row.label; + fo.appendChild(div); + gGrid.appendChild(fo); } } diff --git a/frontend/src/client/views/shape-timeline-cv.ts b/frontend/src/client/views/shape-timeline-cv.ts index 9d1f39b..18f482a 100644 --- a/frontend/src/client/views/shape-timeline-cv.ts +++ b/frontend/src/client/views/shape-timeline-cv.ts @@ -7,6 +7,7 @@ import { } from "./shape-timeline-chart"; import type { LaneInfo, TimelineEvent } from "./shape-timeline"; import type { RenderSpec, ViewRow } from "./types"; +import { t } from "../i18n"; // shape-timeline-cv (t-paliad-177 Slice 4, faraday-Q7) — Custom Views // host for the chart renderer. @@ -23,6 +24,12 @@ import type { RenderSpec, ViewRow } from "./types"; // // Design ref: docs/design-project-chart-2026-05-09.md §8.3 + §11.5 + §13.4. +// Zoom levels in ascending span (t-paliad-211). Width-only — the chart's +// existing range presets already provide three meaningful zoom levels. +// Stored in URL as ?tl_zoom=1y|2y|all. +const ZOOM_LEVELS: RangePreset[] = ["1y", "2y", "all"]; +const ZOOM_PARAM = "tl_zoom"; + export function renderTimelineShape( host: HTMLElement, rows: ReadonlyArray, @@ -35,21 +42,127 @@ export function renderTimelineShape( const { events, lanes } = adapt(rows); const cfg = render.timeline ?? {}; + // Resolve the initial zoom: URL > render spec > "1y" default. + const initialZoom = resolveInitialZoom(cfg.range_preset); + + // Toolbar lives above the chart in its own row so it doesn't compete + // with the date-axis / lane labels for space. + const toolbar = document.createElement("div"); + toolbar.className = "views-timeline-toolbar"; + host.appendChild(toolbar); + + const chartHost = document.createElement("div"); + chartHost.className = "views-timeline-chart-host-inner"; + host.appendChild(chartHost); + // The CV adapter has no per-project "id" to fetch live timeline data // for — we hand mount() a placeholder projectId and the staticData // pre-loaded array so it skips the project endpoint entirely. If the // user clicks a mark, the renderer's default click handler still // resolves /deadlines/{id} / /appointments/{id} from the adapted // event's id field, so deep-links land on the correct entity page. - return mount(host, { + const handle = mount(chartHost, { projectId: "cv", staticData: { events, lanes }, palette: (cfg.palette as Palette | undefined) ?? "default", density: (cfg.density as Density | undefined) ?? "standard", - rangePreset: (cfg.range_preset as RangePreset | undefined) ?? "1y", + rangePreset: initialZoom, rangeFrom: cfg.range_from, rangeTo: cfg.range_to, }); + + let currentZoom = initialZoom; + const setZoom = (next: RangePreset) => { + if (next === currentZoom) return; + currentZoom = next; + handle.setRange(next); + writeZoomURL(next); + paintToolbar(); + }; + + const paintToolbar = () => { + toolbar.innerHTML = ""; + + const zoomGroup = document.createElement("div"); + zoomGroup.className = "views-timeline-zoom-group"; + + const zoomLabel = document.createElement("span"); + zoomLabel.className = "views-timeline-zoom-label"; + zoomLabel.textContent = t("views.timeline.zoom.label"); + zoomGroup.appendChild(zoomLabel); + + const zoomOut = document.createElement("button"); + zoomOut.type = "button"; + zoomOut.className = "btn-secondary btn-small views-timeline-zoom-btn"; + zoomOut.setAttribute("aria-label", t("views.timeline.zoom.out")); + zoomOut.title = t("views.timeline.zoom.out"); + zoomOut.textContent = "−"; + zoomOut.disabled = currentZoom === ZOOM_LEVELS[ZOOM_LEVELS.length - 1]; + zoomOut.addEventListener("click", () => { + const idx = ZOOM_LEVELS.indexOf(currentZoom); + if (idx < ZOOM_LEVELS.length - 1) setZoom(ZOOM_LEVELS[idx + 1]); + }); + zoomGroup.appendChild(zoomOut); + + // Active-level chips (1y / 2y / all). Clicking jumps directly. + const chips = document.createElement("div"); + chips.className = "views-timeline-zoom-chips agenda-chip-row"; + for (const level of ZOOM_LEVELS) { + const chip = document.createElement("button"); + chip.type = "button"; + chip.className = "agenda-chip views-timeline-zoom-chip" + + (level === currentZoom ? " agenda-chip-active" : ""); + chip.dataset.zoom = level; + chip.textContent = t(zoomLevelKey(level)); + chip.addEventListener("click", () => setZoom(level)); + chips.appendChild(chip); + } + zoomGroup.appendChild(chips); + + const zoomIn = document.createElement("button"); + zoomIn.type = "button"; + zoomIn.className = "btn-secondary btn-small views-timeline-zoom-btn"; + zoomIn.setAttribute("aria-label", t("views.timeline.zoom.in")); + zoomIn.title = t("views.timeline.zoom.in"); + zoomIn.textContent = "+"; + zoomIn.disabled = currentZoom === ZOOM_LEVELS[0]; + zoomIn.addEventListener("click", () => { + const idx = ZOOM_LEVELS.indexOf(currentZoom); + if (idx > 0) setZoom(ZOOM_LEVELS[idx - 1]); + }); + zoomGroup.appendChild(zoomIn); + + toolbar.appendChild(zoomGroup); + }; + + paintToolbar(); + + // Apply the URL zoom if it differed from the spec — mount() already + // used initialZoom so this is a no-op when URL was empty. But when URL + // disagreed with the spec, mount() honoured the URL and the toolbar + // already reflects that, so nothing extra to do here. + + return handle; +} + +function zoomLevelKey(level: RangePreset): "views.timeline.zoom.1y" | "views.timeline.zoom.2y" | "views.timeline.zoom.all" { + if (level === "1y") return "views.timeline.zoom.1y"; + if (level === "2y") return "views.timeline.zoom.2y"; + return "views.timeline.zoom.all"; +} + +function resolveInitialZoom(spec: string | undefined): RangePreset { + const params = new URLSearchParams(window.location.search); + const raw = params.get(ZOOM_PARAM); + if (raw && (ZOOM_LEVELS as string[]).includes(raw)) return raw as RangePreset; + if (spec && (ZOOM_LEVELS as string[]).includes(spec)) return spec as RangePreset; + return "1y"; +} + +function writeZoomURL(zoom: RangePreset): void { + const url = new URL(window.location.href); + url.searchParams.set(ZOOM_PARAM, zoom); + history.replaceState(null, "", url.toString()); } export interface AdapterResult { diff --git a/frontend/src/i18n-keys.ts b/frontend/src/i18n-keys.ts index 144d8b9..f9f0eea 100644 --- a/frontend/src/i18n-keys.ts +++ b/frontend/src/i18n-keys.ts @@ -2435,6 +2435,12 @@ export type I18nKey = | "views.source.project_event" | "views.subtitle" | "views.timeline.caveat.body" + | "views.timeline.zoom.1y" + | "views.timeline.zoom.2y" + | "views.timeline.zoom.all" + | "views.timeline.zoom.in" + | "views.timeline.zoom.label" + | "views.timeline.zoom.out" | "views.title" | "views.toast.inaccessible_n" | "views.toast.inaccessible_one"; diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index c40f626..e60044b 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -14793,7 +14793,14 @@ dialog.quick-add-sheet::backdrop { .smart-timeline-chart .chart-lane-label { font-size: 0.85rem; font-weight: 500; - fill: var(--chart-lane-label); + color: var(--chart-lane-label); + height: 100%; + display: flex; + align-items: center; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + cursor: default; } .smart-timeline-chart .chart-today-rule { stroke: var(--chart-today-rule); @@ -14903,6 +14910,45 @@ dialog.quick-add-sheet::backdrop { outline-offset: 2px; } +/* Custom Views timeline toolbar (t-paliad-211) — zoom controls above the + chart canvas. Stays in flow so it doesn't overlap the SVG date axis. */ +.views-timeline-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; + margin-bottom: 12px; +} +.views-timeline-zoom-group { + display: inline-flex; + align-items: center; + gap: 8px; +} +.views-timeline-zoom-label { + font-size: 12px; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} +.views-timeline-zoom-btn { + min-width: 32px; + padding: 4px 10px; + font-weight: 600; +} +.views-timeline-zoom-btn[disabled] { + opacity: 0.5; + cursor: not-allowed; +} +.views-timeline-zoom-chips { + display: inline-flex; + gap: 4px; +} +.views-timeline-chart-host-inner { + /* Reserve a min-height so the loading placeholder doesn't collapse + and the toolbar/chart stack stays predictable. */ + min-height: 200px; +} + /* ---- Palette presets (t-paliad-177 Slice 2, design §5.1) ---- Each palette is a pure data-attribute swap of the --chart-* tokens. Renderer code never reads palette state — it just emits classed SVG