Files
macroplan/src/components/MacroplanGrid.vue
Julien Calixte 8024f950e1 feat(grid): stack milestone labels in a band above the axis
- milestones render as horizontal label flags, auto-stacked onto extra
  rows with leader lines so labels in nearby weeks never collide
- "today" becomes a neutral grey column (mixed in oklab) and milestone
  diamonds are grey too — red is reserved for problems (late, red status,
  unmet counts), which no longer get a stray warm tint
- fix the name column to a stable width so the band aligns with the grid
2026-06-17 01:18:34 +02:00

436 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed } from 'vue'
import type { Plan, FeatureRow, MarkerKind } from '../model/types'
import { weekLabel, type WeekId } from '../model/week'
const props = defineProps<{ plan: Plan }>()
type Tone = 'success' | 'warning' | 'error' | 'neutral'
const GLYPH: Record<MarkerKind, string> = {
original: '◯',
reestimate: '△',
'delivered-on-time': '◉',
'delivered-late': '▲',
}
const MARKER_CLASS: Record<MarkerKind, string> = {
original: 'text-base-content/50',
reestimate: 'text-warning',
'delivered-on-time': 'text-success',
'delivered-late': 'text-error',
}
// When two markers land on the same week, the higher rank owns the cell.
const RANK: Record<MarkerKind, number> = {
'delivered-late': 3,
'delivered-on-time': 3,
reestimate: 2,
original: 1,
}
const TONE_TEXT: Record<Tone, string> = {
success: 'text-success',
warning: 'text-warning',
error: 'text-error',
neutral: 'text-primary',
}
const TONE_BORDER: Record<Tone, string> = {
success: 'border-success',
warning: 'border-warning',
error: 'border-error',
neutral: 'border-primary',
}
const TONE_DOT: Record<Tone, string> = {
success: 'bg-success',
warning: 'bg-warning',
error: 'bg-error',
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: `var(--name-w) repeat(${weeks.value.length}, var(--wk)) minmax(9rem, 1fr)`,
}))
function tone(row: FeatureRow): Tone {
if (row.delivered) return row.onTime ? 'success' : 'error'
if (row.status === 'green') return 'success'
if (row.status === 'orange') return 'warning'
if (row.status === 'red') return 'error'
return 'neutral'
}
function statusWord(row: FeatureRow): string {
return row.status === 'green'
? 'on track'
: row.status === 'orange'
? 'at risk'
: row.status === 'red'
? 'blocked'
: 'in flight'
}
function markerAt(row: FeatureRow, w: WeekId): MarkerKind | null {
let best: MarkerKind | null = null
for (const m of row.markers) {
if (m.week === w && (best === null || RANK[m.kind] > RANK[best])) best = m.kind
}
return best
}
interface Cell {
// 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
glyphCls: string
}
// rows × weeks render matrix, computed once.
const matrix = computed<Cell[][]>(() =>
props.plan.rows.map((row) =>
weeks.value.map((w) => {
const m = markerAt(row, w)
const inBar = w >= row.startWeek && w <= row.barEndWeek
let line: Cell['line'] = 'none'
if (inBar) {
const isStart = w === row.startWeek
const isEnd = w === row.barEndWeek
line = isStart && isEnd ? 'none' : isStart ? 'right' : isEnd ? 'left' : 'full'
}
return {
line,
isStart: inBar && w === row.startWeek,
glyph: m ? GLYPH[m] : '',
glyphCls: m ? MARKER_CLASS[m] : '',
}
}),
),
)
// 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,
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.isMilestone) return 'col-ms'
return ''
}
// 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)`,
}))
</script>
<template>
<div class="macroplan overflow-auto rounded-box border border-base-300 bg-base-100">
<!-- milestone band: stacked label flags with leader lines down to the axis -->
<div v-if="milestoneFlags.length" class="ms-band" :style="bandStyle">
<template v-for="f in milestoneFlags" :key="f.name + '-' + f.col">
<i class="ms-lead" :style="{ gridColumn: f.col, gridRow: f.row + ' / -1' }" />
<span class="ms-flag" :style="{ gridColumn: f.col, gridRow: f.row }" :title="f.title">
<span class="ms-dia"></span> {{ f.name
}}<span v-if="f.unmet.length" class="ms-unmet"> · {{ f.unmet.length }} unmet</span>
</span>
</template>
</div>
<div class="plan-grid" :style="gridStyle">
<!-- header row -->
<div class="hcell corner">{{ plan.title }}</div>
<div
v-for="(c, ci) in cols"
:key="'h-' + c.w"
class="hcell wkhead"
:class="[colClass(ci), { 'now-head': c.isNow }]"
>
<span class="wklabel">{{ c.label }}</span>
<span v-if="c.isNow" class="badge-now">now</span>
</div>
<div class="hcell learnhead">Learning / Status</div>
<!-- feature rows -->
<template v-for="(row, ri) in plan.rows" :key="row.name">
<div class="namecell border-l-4" :class="TONE_BORDER[tone(row)]">
<span class="truncate" :title="row.name">{{ row.name }}</span>
</div>
<div
v-for="(cell, ci) in matrix[ri]"
:key="row.name + '-' + ci"
class="cell"
:class="colClass(ci)"
>
<i v-if="cell.line !== 'none'" class="bar" :class="[cell.line, TONE_TEXT[tone(row)]]"></i>
<i v-if="cell.isStart" class="riser" :class="TONE_TEXT[tone(row)]"></i>
<span v-if="cell.glyph" class="glyph" :class="cell.glyphCls">{{ cell.glyph }}</span>
</div>
<div class="learncell">
<template v-if="row.delivered">
<span v-if="row.learning" class="note">{{ row.learning }}</span>
<span v-else class="muted">{{ row.onTime ? 'delivered on time' : 'delivered late' }}</span>
</template>
<template v-else>
<span class="dot" :class="TONE_DOT[tone(row)]"></span>
<span class="note" :title="row.note || ''">{{ row.note || statusWord(row) }}</span>
</template>
</div>
</template>
</div>
</div>
<!-- legend -->
<div class="legend">
<span><b class="text-base-content/50"></b> original estimate</span>
<span><b class="text-warning"></b> re-estimate</span>
<span><b class="text-success"></b> on time</span>
<span><b class="text-error"></b> late</span>
<span><b></b> feature bar</span>
<span><b class="ms-dia"></b> milestone</span>
<span><b class="now-swatch"></b> today</span>
</div>
</template>
<style scoped>
.macroplan {
--name-w: 9rem;
--wk: 3.5rem;
}
.plan-grid {
display: grid;
width: max-content;
min-width: 100%;
font-variant-ligatures: none;
}
/* ── Milestone band ───────────────────────────────────────────────── */
.ms-band {
display: grid;
width: max-content;
min-width: 100%;
padding-top: 0.35rem;
font-variant-ligatures: none;
}
.ms-flag {
align-self: center;
white-space: nowrap;
overflow: visible;
font-size: 0.66rem;
font-weight: 600;
line-height: 1;
color: var(--color-base-content);
z-index: 1;
}
.ms-dia {
color: color-mix(in oklab, var(--color-base-content) 55%, var(--color-base-100));
}
.ms-unmet {
color: var(--color-error); /* unmet required features = a problem = red */
font-weight: 700;
}
.ms-lead {
justify-self: start;
width: 0;
border-left: 2px dashed color-mix(in oklab, var(--color-base-content) 30%, var(--color-base-100));
pointer-events: none;
}
.hcell {
position: sticky;
top: 0;
z-index: 20;
background: var(--color-base-200);
border-bottom: 1px solid var(--color-base-300);
padding: 0.4rem 0.5rem;
font-size: 0.7rem;
font-weight: 600;
white-space: nowrap;
}
.corner {
left: 0;
z-index: 30;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
}
.wkhead {
display: flex;
flex-direction: column;
align-items: center;
line-height: 1.15;
padding-inline: 0.15rem;
}
.wklabel {
font-variant-numeric: tabular-nums;
}
.now-head {
color: var(--color-primary);
}
.badge-now {
font-size: 0.58rem;
font-weight: 700;
color: var(--color-primary);
}
.learnhead {
text-align: left;
}
.namecell {
position: sticky;
left: 0;
z-index: 10;
background: var(--color-base-100);
display: flex;
align-items: center;
min-height: 2rem;
padding: 0 0.6rem;
font-size: 0.8rem;
font-weight: 500;
border-bottom: 1px solid var(--color-base-200);
}
.namecell .truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cell {
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-height: 2rem;
border-bottom: 1px solid var(--color-base-200);
}
.bar {
position: absolute;
top: 50%;
height: 2px;
transform: translateY(-50%);
background: currentColor;
opacity: 0.55;
}
.bar.full {
left: 0;
right: 0;
}
.bar.left {
left: 0;
right: 50%;
} /* enters from the left, stops at the glyph */
.bar.right {
left: 50%;
right: 0;
} /* starts at the first glyph, runs right */
.riser {
position: absolute;
left: 50%;
top: 28%;
bottom: 28%;
width: 2px;
transform: translateX(-50%);
background: currentColor;
opacity: 0.55;
}
.glyph {
position: relative;
z-index: 1;
font-size: 0.95rem;
line-height: 1;
padding: 0 0.18rem;
/* halo so the bar reads as passing behind, not through, the symbol */
background: var(--color-base-100);
border-radius: 0.3rem;
}
.learncell {
display: flex;
align-items: center;
gap: 0.4rem;
min-height: 2rem;
padding: 0 0.6rem;
font-size: 0.72rem;
border-bottom: 1px solid var(--color-base-200);
}
.learncell .muted {
opacity: 0.5;
}
.learncell .note {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
opacity: 0.8;
}
.dot {
width: 0.6rem;
height: 0.6rem;
border-radius: 9999px;
flex: none;
}
/* "today" — a neutral grey column (red is reserved for problems) */
.cell.col-now,
.cell.col-now .glyph {
background: color-mix(in oklab, var(--color-base-content) 7%, var(--color-base-100));
}
.col-ms {
border-left: 2px dashed color-mix(in oklab, var(--color-base-content) 30%, var(--color-base-100));
}
.legend {
margin-top: 0.75rem;
display: flex;
flex-wrap: wrap;
gap: 0.25rem 1.25rem;
font-size: 0.72rem;
opacity: 0.85;
}
.legend b {
font-weight: 700;
}
.now-swatch {
display: inline-block;
width: 0.8rem;
height: 0.8rem;
vertical-align: -0.1rem;
border-radius: 0.15rem;
background: color-mix(in oklab, var(--color-base-content) 7%, var(--color-base-100));
border: 1px solid color-mix(in oklab, var(--color-base-content) 22%, var(--color-base-100));
}
</style>