diff --git a/src/components/MacroplanGrid.vue b/src/components/MacroplanGrid.vue index a93656e..875bc19 100644 --- a/src/components/MacroplanGrid.vue +++ b/src/components/MacroplanGrid.vue @@ -45,10 +45,15 @@ const TONE_DOT: Record = { neutral: 'bg-base-300', } +// Layout constants (must match the CSS vars --name-w / --wk) for stacking math. +const NAME_W = 9 * 16 +const WK = 3.5 * 16 +const BAND_CHAR = 6.6 // ≈ Fira Code advance (px) at the band font size + const weeks = computed(() => props.plan.weeks) const gridStyle = computed(() => ({ - gridTemplateColumns: `minmax(7rem, max-content) repeat(${weeks.value.length}, var(--wk)) minmax(9rem, 1fr)`, + gridTemplateColumns: `var(--name-w) repeat(${weeks.value.length}, var(--wk)) minmax(9rem, 1fr)`, })) function tone(row: FeatureRow): Tone { @@ -78,8 +83,8 @@ function markerAt(row: FeatureRow, w: WeekId): MarkerKind | null { } interface Cell { - // how far the bar line runs within this cell: none, half-from-center-right, - // half-from-left-to-center, or full width + // how far the bar line runs within this cell: none, center→right, + // left→center, or full width line: 'none' | 'right' | 'left' | 'full' isStart: boolean glyph: string @@ -96,8 +101,6 @@ const matrix = computed(() => if (inBar) { const isStart = w === row.startWeek const isEnd = w === row.barEndWeek - // line runs center→edge so it begins and ends at a glyph centre, - // never stubbing past the first/last symbol line = isStart && isEnd ? 'none' : isStart ? 'right' : isEnd ? 'left' : 'full' } return { @@ -110,32 +113,64 @@ const matrix = computed(() => ), ) -// Per-week column metadata for headers + the now / milestone vertical rules. +// Per-week column metadata for the header + the now / milestone column rules. const cols = computed(() => weeks.value.map((w) => ({ w, label: weekLabel(w), isNow: props.plan.nowInRange && w === props.plan.nowWeek, - milestones: props.plan.milestones.filter((m) => m.week === w), + isMilestone: props.plan.milestones.some((m) => m.week === w), })), ) function colClass(i: number): string { const c = cols.value[i] if (c.isNow) return 'col-now' - if (c.milestones.length) return 'col-ms' + if (c.isMilestone) return 'col-ms' return '' } -function milestoneTitle(ms: Plan['milestones']): string { - return ms - .map((m) => (m.unmet.length ? `${m.name} — unmet: ${m.unmet.join(', ')}` : `${m.name} — all met`)) - .join(' · ') -} +// Milestone label flags above the axis, greedily stacked onto extra rows so +// labels in nearby weeks never overlap. `col` is the 1-based grid column. +const milestoneFlags = computed(() => { + const items = props.plan.milestones + .map((m) => ({ m, col: weeks.value.indexOf(m.week) + 2 })) + .filter((x) => x.col >= 2) + .sort((a, b) => a.col - b.col) + const rowEnd: number[] = [] // px x-extent of the last flag placed in each row + return items.map((x) => { + const startX = NAME_W + (x.col - 2) * WK + const text = `◆ ${x.m.name}` + (x.m.unmet.length ? ` · ${x.m.unmet.length} unmet` : '') + const width = text.length * BAND_CHAR + 14 + let row = 0 + while (row < rowEnd.length && rowEnd[row] > startX - 6) row++ + rowEnd[row] = startX + width + const title = x.m.unmet.length + ? `${x.m.name} — unmet: ${x.m.unmet.join(', ')}` + : `${x.m.name} — all required features met` + return { name: x.m.name, unmet: x.m.unmet, col: x.col, row: row + 1, title } + }) +}) +const bandRows = computed(() => milestoneFlags.value.reduce((n, f) => Math.max(n, f.row), 0)) +const bandStyle = computed(() => ({ + gridTemplateColumns: `var(--name-w) repeat(${weeks.value.length}, var(--wk))`, + gridTemplateRows: `repeat(${bandRows.value}, 1.15rem)`, +}))