// Project-level progress for Progress mode: the layer Buildots puts on top of // per-area stage reads — where each area sits on the floor plan, whether it is // ahead of or behind its planned dates, which areas nobody has walked lately, // and one report across every area and trade. // // Progress mode already stores, per named area, a plan (what the area should // contain) and dated captures (overall % and per-trade items, from // rollupProgress). This module only reads that store plus three optional // fields per area, all entered by the user — BuildVision has no BIM and no // schedule import: // // area.schedule = { start, finish, trades?: { [itemKey]: { start, finish } } } (ms timestamps) // area.floor = 'L3' (else parsed from the area name: "L3 east corridor") // area.pin = { plan, x, y } (plan id, x/y as 0..1 of the plan image) // // and store.plans = { [id]: { name, image } } for the floor-plan images. // // Planned progress is linear between an area's start and finish — the same // straight-line reading a Gantt bar gives, not a resource-loaded curve — and // the UI says so. Everything here is a pure function (tests/progress-project.test.mjs). const DAY = 86400000; export const ON_TRACK_POINTS = 5; // |actual − planned| within this many points reads as on track export const STALE_DAYS = 7; // an unfinished area not captured in this long is flagged export const SLIP_TOLERANCE_DAYS = 2; export const STATUS = { complete: { label: 'Complete', color: '#2f9e44', rank: 0 }, ahead: { label: 'Ahead', color: '#1c7ed6', rank: 1 }, on_track: { label: 'On track', color: '#74b816', rank: 2 }, at_risk: { label: 'At risk', color: '#f59f00', rank: 4 }, behind: { label: 'Behind', color: '#e03131', rank: 5 }, not_started_late: { label: 'Should have started', color: '#c2255c', rank: 6 }, not_captured: { label: 'Not captured', color: '#868e96', rank: 3 }, unscheduled: { label: 'No schedule', color: '#868e96', rank: 3 }, }; const latestCapture = rec => { const caps = (rec?.captures || []).filter(c => Number.isFinite(c.time)); return caps.length ? caps.reduce((a, b) => (b.time > a.time ? b : a)) : null; }; /** Planned % complete at time t for a { start, finish } window: 0 before, 100 after, linear between. */ export function plannedPctAt(window, t) { if (!window || !Number.isFinite(window.start) || !Number.isFinite(window.finish) || window.finish <= window.start) return null; if (t <= window.start) return 0; if (t >= window.finish) return 100; return Math.round(((t - window.start) / (window.finish - window.start)) * 100); } /** * Least-squares production rate (points/day) over captures; null without two captures a day apart. * Same fit forecastCompletion uses, repeated here so this module stands alone. */ function productionRate(captures) { const pts = (captures || []).filter(c => c.overall != null && Number.isFinite(c.time)).map(c => [c.time / DAY, c.overall]); if (pts.length < 2) return null; const xs = pts.map(p => p[0]); if (Math.max(...xs) - Math.min(...xs) < 1) return null; const mx = xs.reduce((s, x) => s + x, 0) / pts.length, my = pts.reduce((s, p) => s + p[1], 0) / pts.length; const sxx = pts.reduce((s, p) => s + (p[0] - mx) ** 2, 0); return pts.reduce((s, p) => s + (p[0] - mx) * (p[1] - my), 0) / sxx; } /** * Planned vs actual for one window and one actual reading taken at `at`. * daysBehind is schedule time: how long ago the plan expected today's actual % * (positive = behind, negative = ahead) — the number a superintendent quotes. */ function varianceOf(window, actual, at) { const planned = plannedPctAt(window, at); if (planned == null || actual == null) return { planned, actual, variancePts: null, daysBehind: null }; const whenPlanned = window.start + (Math.min(100, actual) / 100) * (window.finish - window.start); return { planned, actual, variancePts: actual - planned, daysBehind: Math.round((at - whenPlanned) / DAY) }; } /** * Where one area stands against its schedule, from its latest capture. * Status precedence: complete > (no schedule) > should-have-started > behind > at risk > ahead > on track. * "At risk" = within the on-track band today but the production rate lands * the finish more than SLIP_TOLERANCE_DAYS after the planned finish. */ export function areaStatus(rec, now = Date.now(), { staleDays = STALE_DAYS } = {}) { const last = latestCapture(rec); const schedule = rec?.schedule; const actual = last?.overall ?? null; const lastCaptureDays = last ? Math.floor((now - last.time) / DAY) : null; const complete = actual != null && actual >= 100; const stale = !complete && last != null && lastCaptureDays > staleDays; const base = { actual, lastCapture: last?.time ?? null, lastCaptureDays, stale, captures: (rec?.captures || []).length, items: last?.items || [] }; const rate = productionRate(rec?.captures); let projectedFinish = null; if (complete) projectedFinish = last.time; else if (rate > 0.05 && last) projectedFinish = Math.max(now, last.time + ((100 - actual) / rate) * DAY); const withSchedule = Number.isFinite(schedule?.start) && Number.isFinite(schedule?.finish) && schedule.finish > schedule.start; const plannedFinish = withSchedule ? schedule.finish : null; const slipDays = projectedFinish != null && plannedFinish != null ? Math.round((projectedFinish - plannedFinish) / DAY) : null; const trades = tradeStatus(rec, last); if (complete) return { ...base, status: 'complete', planned: withSchedule ? plannedPctAt(schedule, now) : null, variancePts: null, daysBehind: null, rate, projectedFinish, plannedFinish, slipDays, trades }; if (!withSchedule) { return { ...base, status: last ? 'unscheduled' : 'not_captured', planned: null, variancePts: null, daysBehind: null, rate, projectedFinish, plannedFinish: null, slipDays: null, trades }; } if (!last) { const planned = plannedPctAt(schedule, now); return { ...base, status: now > schedule.start ? 'not_started_late' : 'not_captured', planned, variancePts: null, daysBehind: now > schedule.start ? Math.round((now - schedule.start) / DAY) : null, rate, projectedFinish, plannedFinish, slipDays, trades }; } // Judge the latest capture against the plan *at that capture's date*: a // week-old 40% is not "behind" today's 70% plan by 30 points, it is stale. const v = varianceOf(schedule, actual, last.time); let status; if (v.variancePts < -ON_TRACK_POINTS) status = 'behind'; else if (slipDays != null && slipDays > SLIP_TOLERANCE_DAYS) status = 'at_risk'; else if (rate != null && !(rate > 0.05) && plannedPctAt(schedule, now) > actual + ON_TRACK_POINTS) status = 'at_risk'; // stalled else if (v.variancePts > ON_TRACK_POINTS) status = 'ahead'; else status = 'on_track'; return { ...base, status, ...v, rate, projectedFinish, plannedFinish, slipDays, trades }; } /** Per-trade variance for trades that have their own planned window. */ function tradeStatus(rec, last) { const windows = rec?.schedule?.trades || {}; return (last?.items || []).filter(i => windows[i.key]).map(i => { const v = varianceOf(windows[i.key], i.pct ?? null, last.time); const status = i.pct >= 100 ? 'complete' : v.variancePts == null ? 'not_captured' : v.variancePts < -ON_TRACK_POINTS ? 'behind' : v.variancePts > ON_TRACK_POINTS ? 'ahead' : 'on_track'; return { key: i.key, trade: i.trade, status, ...v }; }); } /** "L3", "Level 3", "Floor 03", "3F", "B1", "Basement 2" at the start of an area name → a floor key. */ export function floorOf(name, rec) { if (rec?.floor) return String(rec.floor).trim(); const s = String(name || '').trim(); let m = s.match(/^(?:l|lvl|level|fl|floor)\s*-?\s*0*(\d+)\b/i) || s.match(/^0*(\d+)\s*(?:f|fl)\b/i); if (m) return `L${Number(m[1])}`; m = s.match(/^(?:b|basement)\s*-?\s*0*(\d+)\b/i); if (m) return `B${Number(m[1])}`; if (/^(?:roof|rf)\b/i.test(s)) return 'Roof'; if (/^(?:ground|gf|g)\b/i.test(s)) return 'L0'; return 'Unassigned'; } const floorOrder = f => (f === 'Unassigned' ? 1e6 : f === 'Roof' ? 1e5 : f[0] === 'B' ? -Number(f.slice(1)) : Number(f.slice(1)) || 0); /** * Every area, rolled up: per-area status, per-floor and per-trade averages from * each area's latest capture, and an attention list (behind / at risk / late * starts / stale) ordered worst first. Averages count captured areas only and * say how many they cover, so one captured room is never presented as the floor. */ export function projectRollup(store, now = Date.now(), opts = {}) { const areas = Object.entries(store?.areas || {}).map(([name, rec]) => ({ name, floor: floorOf(name, rec), pin: rec.pin || null, ...areaStatus(rec, now, opts), })); const captured = areas.filter(a => a.actual != null); const mean = xs => (xs.length ? Math.round(xs.reduce((s, x) => s + x, 0) / xs.length) : null); const floors = {}; for (const a of areas) { const f = (floors[a.floor] ||= { floor: a.floor, areas: 0, captured: 0, pcts: [], behind: 0, atRisk: 0, stale: 0 }); f.areas += 1; if (a.actual != null) { f.captured += 1; f.pcts.push(a.actual); } if (a.status === 'behind' || a.status === 'not_started_late') f.behind += 1; if (a.status === 'at_risk') f.atRisk += 1; if (a.stale) f.stale += 1; } const floorList = Object.values(floors).map(({ pcts, ...f }) => ({ ...f, pct: mean(pcts) })) .sort((a, b) => floorOrder(a.floor) - floorOrder(b.floor)); const tradeAcc = {}; for (const [name, rec] of Object.entries(store?.areas || {})) { const last = latestCapture(rec); (last?.items || []).forEach(i => { if (i.pct == null) return; const t = (tradeAcc[i.key] ||= { key: i.key, trade: i.trade, pcts: [], areas: [] }); t.pcts.push(i.pct); t.areas.push(name); }); } const trades = Object.values(tradeAcc).map(t => ({ key: t.key, trade: t.trade, pct: mean(t.pcts), areas: t.areas.length, complete: t.pcts.filter(p => p >= 100).length })); const counts = Object.fromEntries(Object.keys(STATUS).map(k => [k, 0])); areas.forEach(a => { counts[a.status] += 1; }); const attention = areas .filter(a => ['behind', 'at_risk', 'not_started_late'].includes(a.status) || a.stale || a.trades?.some(t => t.status === 'behind')) .map(a => ({ name: a.name, floor: a.floor, status: a.status, stale: a.stale, daysBehind: a.daysBehind, slipDays: a.slipDays, lastCaptureDays: a.lastCaptureDays, reason: attentionReason(a) })) .sort((a, b) => STATUS[b.status].rank - STATUS[a.status].rank || (b.daysBehind ?? 0) - (a.daysBehind ?? 0) || (b.lastCaptureDays ?? 0) - (a.lastCaptureDays ?? 0)); return { areas, floors: floorList, trades, counts, attention, overall: mean(captured.map(a => a.actual)), capturedAreas: captured.length, totalAreas: areas.length }; } function attentionReason(a) { const parts = []; if (a.status === 'behind') parts.push(`${a.actual}% vs ${a.planned}% planned at last capture (${a.daysBehind} day${a.daysBehind === 1 ? '' : 's'} behind)`); if (a.status === 'not_started_late') parts.push(`planned to start ${a.daysBehind} day${a.daysBehind === 1 ? '' : 's'} ago, never captured`); if (a.status === 'at_risk') parts.push(a.slipDays != null ? `current rate finishes ${a.slipDays} days after the planned finish` : 'no progress between recent captures'); const lateTrades = (a.trades || []).filter(t => t.status === 'behind'); if (lateTrades.length) parts.push(`trade${lateTrades.length === 1 ? '' : 's'} behind: ${lateTrades.map(t => `${t.trade} ${t.actual}% vs ${t.planned}% (${t.daysBehind} day${t.daysBehind === 1 ? '' : 's'})`).join(', ')}`); if (a.stale) parts.push(`last captured ${a.lastCaptureDays} days ago`); return parts.join('; '); } // ---------- quantities from a scaled floor plan ---------- // // With a plan scale (feet per plan pixel, from two clicked points and a known // distance) and the room each area is pinned in, a stage read becomes square // feet: floor area from the room polygon, wall area from its perimeter × the // ceiling height. Stage → how far along the trade sequence the surface is. // This is the room outline as drawn (interior partitions inside a room, and // openings, are not deducted) — a progress quantity, not a takeoff. const STAGE_PCT = { wall_framing: 25, wall_board: 50, wall_taped: 75, wall_finished: 100, ceiling_open: 33, ceiling_grid: 67, ceiling_finished: 100 }; export const WALL_MILESTONES = [['Framed', 25], ['Board hung', 50], ['Taped & mudded', 75], ['Finished', 100]]; export const CEILING_MILESTONES = [['Grid hung', 67], ['Closed', 100]]; /** Shoelace area and perimeter of a 0..1 polygon on a W×H plan, in plan pixels. */ export function polygonMetrics(poly, W, H) { let a = 0, per = 0; for (let i = 0; i < poly.length; i++) { const [x1, y1] = poly[i], [x2, y2] = poly[(i + 1) % poly.length]; a += x1 * W * y2 * H - x2 * W * y1 * H; per += Math.hypot((x2 - x1) * W, (y2 - y1) * H); } return { area: Math.abs(a) / 2, perimeter: per }; } /** * Square feet per pinned, measurable area and totals by milestone: how much * wall has reached "board hung" or beyond, how much ceiling is closed, etc. * Areas without a scaled plan, a detected room around the pin, or a reading * are listed as unmeasured rather than guessed. */ export function areaQuantities(rollup, plans = {}, { ceilingHeightFt = 9 } = {}) { const perArea = [], unmeasured = []; for (const a of rollup.areas) { const plan = a.pin && plans[a.pin.plan]; const room = plan?.rooms && roomAt(plan.rooms, a.pin.x, a.pin.y); if (!plan?.scale?.ftPerPx || !plan.size || !room) { unmeasured.push(a.name); continue; } const m = polygonMetrics(room.poly, plan.size.w, plan.size.h); const f = plan.scale.ftPerPx; const wall = a.items.find(i => i.key === 'wall'), ceiling = a.items.find(i => i.key === 'ceiling'); perArea.push({ name: a.name, floorSf: Math.round(m.area * f * f), wallSf: Math.round(m.perimeter * f * ceilingHeightFt), wallStage: wall?.stage || null, ceilingStage: ceiling?.stage || null }); } const sumBy = (key, stageKey, milestones) => { const total = perArea.reduce((s, q) => s + q[key], 0); const read = perArea.filter(q => q[stageKey]).reduce((s, q) => s + q[key], 0); return { total, read, milestones: milestones.map(([label, pct]) => ({ label, sf: perArea .filter(q => q[stageKey] && STAGE_PCT[q[stageKey]] >= pct).reduce((s, q) => s + q[key], 0) })) }; }; return { perArea, unmeasured, ceilingHeightFt, walls: sumBy('wallSf', 'wallStage', WALL_MILESTONES), ceilings: sumBy('floorSf', 'ceilingStage', CEILING_MILESTONES) }; } /** Colour ramp for a % complete (grey for no reading) — the plan heatmap. */ export function pctColor(pct) { if (pct == null) return '#868e96'; const stops = [[0, [224, 49, 49]], [50, [245, 159, 0]], [100, [47, 158, 68]]]; const p = Math.max(0, Math.min(100, pct)); const i = p <= 50 ? 0 : 1; const [p0, c0] = stops[i], [p1, c1] = stops[i + 1]; const t = (p - p0) / (p1 - p0); return `#${c0.map((c, k) => Math.round(c + (c1[k] - c) * t).toString(16).padStart(2, '0')).join('')}`; } /** Pins for one floor-plan image, coloured by % complete or by schedule status. */ export function pinsForPlan(rollup, planId, colorBy = 'pct') { return rollup.areas.filter(a => a.pin && a.pin.plan === planId && Number.isFinite(a.pin.x) && Number.isFinite(a.pin.y)) .map(a => ({ name: a.name, x: a.pin.x, y: a.pin.y, pct: a.actual, status: a.status, stale: a.stale, color: colorBy === 'status' ? STATUS[a.status].color : pctColor(a.actual) })); } /** The pin nearest (x, y) within `radius` (all in the same units), or null. */ export function nearestPin(pins, x, y, radius) { let best = null, bestD = radius; for (const p of pins) { const d = Math.hypot(p.x - x, p.y - y); if (d <= bestD) { best = p; bestD = d; } } return best; } /** Even-odd point-in-polygon; poly is [[x, y], …] in the same units as (x, y). */ export function pointInPolygon(poly, x, y) { let inside = false; for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { const [xi, yi] = poly[i], [xj, yj] = poly[j]; if ((yi > y) !== (yj > y) && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside; } return inside; } /** Smallest detected room containing (x, y) — rooms nest when a closet sits inside a larger outline. */ export function roomAt(rooms, x, y) { let best = null; for (const room of rooms || []) { if (pointInPolygon(room.poly, x, y) && (!best || room.area < best.area)) best = room; } return best; } /** * Rooms to fill on a plan: each pin claims the room it sits in and paints it * its colour. Two areas pinned in one room share it (the later pin's colour, * both names listed) rather than one silently hiding the other. */ export function roomFills(rooms, pins) { const fills = new Map(); for (const pin of pins) { const room = roomAt(rooms, pin.x, pin.y); if (!room) continue; const f = fills.get(room) || { poly: room.poly, names: [], color: pin.color, pct: pin.pct }; f.names.push(pin.name); f.color = pin.color; f.pct = pin.pct; fills.set(room, f); } return [...fills.values()]; } const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); const day = t => (Number.isFinite(t) ? new Date(t).toISOString().slice(0, 10) : '—'); /** * A self-contained, printable HTML progress report (the browser's Print → PDF * makes the PDF). Every number comes from projectRollup; nothing is restated by hand. */ export function progressReportHtml(rollup, { project = 'Project', now = Date.now(), planImages = [], quantities = null, facades = [], curve = null } = {}) { const r = rollup; const pctCell = p => (p == null ? '—' : `${p}%`); const statusCell = s => `${STATUS[s].label}`; const rows = [...r.areas].sort((a, b) => floorOrder(a.floor) - floorOrder(b.floor) || a.name.localeCompare(b.name)); return `${esc(project)} — progress ${day(now)}

${esc(project)} — progress report

${day(now)} · ${r.capturedAreas} of ${r.totalAreas} areas captured · BuildVision Progress mode

${pctCell(r.overall)}average of captured areas
${r.counts.behind + r.counts.not_started_late}behind
${r.counts.at_risk}at risk
${r.areas.filter(a => a.stale).length}not captured in ${STALE_DAYS}+ days
${curve?.points.length >= 2 ? curveHtml(curve) : ''} ${r.attention.length ? `

Needs attention

${r.attention.map(a => ``).join('\n')}
AreaFloorStatusWhy
${esc(a.name)}${esc(a.floor)}${statusCell(a.status)}${esc(a.reason)}
` : ''}

By floor

${r.floors.map(f => ``).join('\n')}
FloorAreasCapturedComplete (avg)BehindAt riskStale
${esc(f.floor)}${f.areas}${f.captured}${pctCell(f.pct)}${f.behind}${f.atRisk}${f.stale}
${r.trades.length ? `

By trade

${r.trades.map(t => ``).join('\n')}
TradeAreas readComplete (avg)Areas at 100%
${esc(t.trade)}${t.areas}${pctCell(t.pct)}${t.complete}
` : ''}

All areas

${rows.map(a => ``).join('\n')}
AreaFloorStatusActualPlannedDays behindLast captureProjected finishPlanned finish
${esc(a.name)}${esc(a.floor)}${statusCell(a.status)}${pctCell(a.actual)}${pctCell(a.planned)}${a.daysBehind ?? '—'}${day(a.lastCapture)}${a.stale ? ' (stale)' : ''}${day(a.projectedFinish)}${day(a.plannedFinish)}
${quantities?.perArea.length ? quantitiesHtml(quantities) : ''} ${facades.length ? facadesHtml(facades) : ''} ${planImages.map(p => `

${esc(p.name)}

${esc(p.name)} with area pins`).join('\n')}

Stages are read by a trained image model from photos and are a prompt to look, not a quantity survey. Planned % is a straight line between each area's planned start and finish. Projected finish is the least-squares rate across that area's captures. Days behind is how long before the capture date the plan expected the % the capture read.

`; } const sf = n => `${Math.round(n).toLocaleString('en-US')} sf`; function quantitiesHtml(q) { const row = (label, part, whole) => `${esc(label)}${sf(part)}${whole > 0 ? `${Math.round((part / whole) * 100)}%` : '—'}`; return `

Quantities (from the scaled floor plan)

${q.walls.milestones.map(m => row(m.label, m.sf, q.walls.total)).join('\n')} ${row('Stage not read', q.walls.total - q.walls.read, q.walls.total)}
Walls (${sf(q.walls.total)} at ${q.ceilingHeightFt} ft ceilings)At or pastShare
${q.ceilings.milestones.map(m => row(m.label, m.sf, q.ceilings.total)).join('\n')}
Ceilings (${sf(q.ceilings.total)})At or pastShare
${q.perArea.map(a => ``).join('\n')}
AreaFloorWallsWall stageCeiling stage
${esc(a.name)}${sf(a.floorSf)}${sf(a.wallSf)}${esc(a.wallStage ? a.wallStage.replace(/^\w+_/, '').replace(/_/g, ' ') : '—')}${esc(a.ceilingStage ? a.ceilingStage.replace(/^\w+_/, '').replace(/_/g, ' ') : '—')}
${q.unmeasured.length ? `

Not measured (no scaled plan, or no room found around the pin): ${esc(q.unmeasured.join(', '))}.

` : ''}`; } /** facades: [{ name, time, summary: { enclosed, partial, open, floors, pct, enclosedThrough }, floors: [{ floor, status, closure }], forecast }] */ function facadesHtml(facades) { const colour = { enclosed: '#2f9e44', partial: '#f59f00', open: '#e03131', unknown: '#868e96' }; return `

Facade (by elevation)

${facades.map(f => ``).join('\n')}
ElevationCapturedEnclosedPartlyOpenClosedFloors (bottom → top)Projection
${esc(f.name)}${day(f.time)}${f.summary.enclosed}/${f.summary.floors}${f.summary.partial}${f.summary.open}${f.summary.pct ?? '—'}% ${[...f.floors].sort((a, b) => a.floor - b.floor).map(fl => ``).join('')} ${esc(f.forecast || '—')}
`; } // ---------- planned vs actual over time (the project S-curve) ---------- /** * Project % complete over time, planned against actual, across the areas that * have planned dates (the only ones a plan exists for). Planned at t is the * mean of each area's straight-line plan; actual at t is the mean of each * area's latest capture on or before t, counting an area with no capture yet * as 0 — so both lines describe the same set of areas. Actual stops at `now`. */ export function projectCurve(store, now = Date.now(), { maxPoints = 60 } = {}) { const areas = Object.values(store?.areas || {}).filter(r => Number.isFinite(r.schedule?.start) && Number.isFinite(r.schedule?.finish) && r.schedule.finish > r.schedule.start); if (!areas.length) return { points: [], areas: 0 }; const caps = areas.flatMap(r => (r.captures || []).map(c => c.time)).filter(Number.isFinite); const t0 = Math.min(...areas.map(r => r.schedule.start), ...caps); const t1 = Math.max(...areas.map(r => r.schedule.finish), Math.min(now, Math.max(now, ...caps))); const steps = Math.max(2, Math.min(maxPoints, Math.ceil((t1 - t0) / DAY) + 1)); const times = [...new Set([...Array.from({ length: steps }, (_, i) => Math.round(t0 + ((t1 - t0) * i) / (steps - 1))), ...caps.filter(t => t <= now)])].sort((a, b) => a - b); const actualAt = (r, t) => { let best = null; for (const c of r.captures || []) if (c.time <= t && c.overall != null && (!best || c.time > best.time)) best = c; return best ? best.overall : 0; }; const mean = xs => Math.round((xs.reduce((s, x) => s + x, 0) / xs.length) * 10) / 10; const points = times.map(t => ({ t, planned: mean(areas.map(r => plannedPctAt(r.schedule, t))), actual: t <= now ? mean(areas.map(r => actualAt(r, t))) : null })); return { points, areas: areas.length, now }; } /** * The curve as a self-contained SVG string: two 2px lines (planned, actual), * end dots with a surface ring, a direct label at each line's end, hairline * gridlines at 0/25/50/75/100, a "today" rule, and one invisible hit column per * point carrying data-* for the hover script. Colours come from CSS custom * properties (--series-1 planned, --series-2 actual, --grid, --text-2, --surface) * so the page supplies light/dark values. */ export function curveSvg(curve, { width = 640, height = 220 } = {}) { const pts = curve.points; if (pts.length < 2) return ''; const L = 40, R = 104, T = 10, B = 24, w = width - L - R, h = height - T - B; const tMin = pts[0].t, tMax = pts[pts.length - 1].t; const X = t => L + ((t - tMin) / Math.max(1, tMax - tMin)) * w, Y = p => T + h - (p / 100) * h; const path = key => pts.filter(p => p[key] != null).map((p, i) => `${i ? 'L' : 'M'}${X(p.t).toFixed(1)},${Y(p[key]).toFixed(1)}`).join(''); const lastOf = key => [...pts].reverse().find(p => p[key] != null); const lp = lastOf('planned'), la = lastOf('actual'); const fmtDay = t => new Date(t).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); const grid = [0, 25, 50, 75, 100].map(g => `${g}%`).join(''); const ticks = [tMin, tMin + (tMax - tMin) / 2, tMax].map((t, i) => `${fmtDay(t)}`).join(''); const today = curve.now >= tMin && curve.now <= tMax ? `today` : ''; // Direct labels at the right edge; pushed apart only when they would overlap, with a leader to the line end. let ya = la ? Y(la.actual) : null, yp = Y(lp.planned); if (ya != null && Math.abs(ya - yp) < 14) { const mid = (ya + yp) / 2; ya = mid + (ya >= yp ? 7 : -7); yp = mid + (ya >= yp ? -7 : 7); } // The line's own end dot is the label's key; a leader runs to the label only when it had to move. const label = (p, key, y, name) => `${Math.abs(y - Y(p[key])) > 1 || X(p.t) < L + w - 1 ? `` : ''}${name} ${Math.round(p[key])}%`; const cols = pts.map((p, i) => { const x0 = i ? (X(pts[i - 1].t) + X(p.t)) / 2 : L, x1 = i < pts.length - 1 ? (X(p.t) + X(pts[i + 1].t)) / 2 : L + w; return ``; }).join(''); return ` ${grid}${ticks}${today} ${la ? `` : ''} ${label(lp, 'planned', yp, 'Planned')}${la ? label(la, 'actual', ya, 'Actual') : ''} ${cols}`; } /** Styles for curveSvg (light + dark). Scoped under .cv. */ export const CURVE_CSS = `.cv,.cv-wrap{--series-1:#2a78d6;--series-2:#eb6834;--grid:#e6e6e3;--text-2:#52514e;--surface:#fcfcfb;overflow:visible;font:11px system-ui,sans-serif} @media (prefers-color-scheme:dark){:root:where(:not([data-theme="light"])) :is(.cv,.cv-wrap){--series-1:#3987e5;--series-2:#d95926;--grid:#33332f;--text-2:#c3c2b7;--surface:#1a1a19}} :root[data-theme="dark"] :is(.cv,.cv-wrap){--series-1:#3987e5;--series-2:#d95926;--grid:#33332f;--text-2:#c3c2b7;--surface:#1a1a19} .cv-grid{stroke:var(--grid);stroke-width:1}.cv-axis{fill:var(--text-2)}.cv-today{stroke:var(--text-2);stroke-width:1;opacity:.5} .cv-planned,.cv-actual{fill:none;stroke-width:2;stroke-linejoin:round;stroke-linecap:round}.cv-planned{stroke:var(--series-1)}.cv-actual{stroke:var(--series-2)} .cv-planned-dot{fill:var(--series-1);stroke:var(--surface);stroke-width:2}.cv-actual-dot{fill:var(--series-2);stroke:var(--surface);stroke-width:2} .cv-leader{stroke:var(--text-2);stroke-width:1;opacity:.35}.cv-label{fill:currentColor;font-weight:600} .cv-cross{stroke:var(--text-2);stroke-width:1}.cv-hit{fill:transparent;cursor:crosshair} .cv-legend{display:flex;flex-wrap:wrap;gap:4px 12px;font:12px system-ui,sans-serif;margin:0 0 4px}.cv-legend span{display:inline-flex;align-items:center;gap:5px} .cv-legend i{display:inline-block;width:14px;height:2px;border-radius:1px}.cv-headline{font:600 13px system-ui,sans-serif;margin:0 0 4px} .cv-tip{position:absolute;pointer-events:none;background:var(--tip-bg,#fff);color:inherit;border:1px solid rgba(0,0,0,.15);border-radius:4px;padding:4px 6px;font:12px system-ui,sans-serif;white-space:nowrap;box-shadow:0 2px 6px rgba(0,0,0,.12)}`; /** * Hover for a curveSvg inside `host` (position:relative): crosshair, both * dots at the hovered date, and a tooltip with the date and both values. * Serialised into the report too (Function#toString), so it must stay self-contained. */ export function attachCurveHover(host) { const svg = host.querySelector('svg.cv'); if (!svg) return; const tip = document.createElement('div'); tip.className = 'cv-tip'; tip.hidden = true; host.append(tip); const cross = svg.querySelector('.cv-cross'), [dp, da] = svg.querySelectorAll('.cv-hover'); const hide = () => { tip.hidden = true; [cross, dp, da].forEach(el => el.setAttribute('visibility', 'hidden')); }; svg.querySelectorAll('.cv-hit').forEach(r => r.addEventListener('pointermove', e => { const d = r.dataset; cross.setAttribute('x1', d.x); cross.setAttribute('x2', d.x); cross.setAttribute('visibility', 'visible'); dp.setAttribute('cx', d.x); dp.setAttribute('cy', d.yp); dp.setAttribute('visibility', 'visible'); if (d.ya) { da.setAttribute('cx', d.x); da.setAttribute('cy', d.ya); da.setAttribute('visibility', 'visible'); } else da.setAttribute('visibility', 'hidden'); tip.textContent = `${d.date} · planned ${Math.round(+d.planned)}%${d.actual ? ` · actual ${Math.round(+d.actual)}%` : ''}`; tip.hidden = false; const box = host.getBoundingClientRect(); tip.style.left = `${Math.min(box.width - tip.offsetWidth, Math.max(0, e.clientX - box.left + 10))}px`; tip.style.top = `${Math.max(0, e.clientY - box.top - 30)}px`; })); svg.addEventListener('pointerleave', hide); } function curveHtml(curve) { const day2 = t => new Date(t).toISOString().slice(0, 10); return `

Planned vs actual (${curve.areas} scheduled area${curve.areas === 1 ? '' : 's'})

${curveLegendHtml(curve)}${curveSvg(curve)}
Show the data ${curve.points.map(p => ``).join('\n')}
DatePlannedActual
${day2(p.t)}${Math.round(p.planned)}%${p.actual == null ? '—' : `${Math.round(p.actual)}%`}
`; } /** Where the project stands today, from the curve: planned vs actual and the gap, for the headline line. */ export function curveToday(curve) { const past = curve.points.filter(p => p.actual != null); const last = past[past.length - 1]; if (!last) return null; const planned = Math.round(last.planned), actual = Math.round(last.actual), gap = actual - planned; // from the shown numbers, so they add up return { planned, actual, gap, text: `Today: ${actual}% complete vs ${planned}% planned — ${gap === 0 ? 'on plan' : `${Math.abs(gap)} points ${gap < 0 ? 'behind' : 'ahead'}`}` }; } /** Legend row + headline above the chart (identity is never colour-only: the lines also carry end labels). */ export function curveLegendHtml(curve) { const today = curveToday(curve); return `${today ? `

${today.text}

` : ''}
Planned (straight line between each area's planned dates)Actual (each area's latest capture)
`; }