data-centre-nameplates / progress-project.js
constructelligence's picture
Upload folder using huggingface_hub
94e56dd verified
Raw History Blame Contribute Delete
35.7 kB
// 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 => `<span class="st" style="--c:${STATUS[s].color}">${STATUS[s].label}</span>`;
const rows = [...r.areas].sort((a, b) => floorOrder(a.floor) - floorOrder(b.floor) || a.name.localeCompare(b.name));
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${esc(project)} — progress ${day(now)}</title>
<style>
body{font:13px/1.45 system-ui,-apple-system,Segoe UI,sans-serif;color:#1a1d21;margin:28px;max-width:1000px}
h1{font-size:20px;margin:0 0 2px}h2{font-size:15px;margin:22px 0 6px;border-bottom:1px solid #dee2e6;padding-bottom:3px}
.sub{color:#5c636a;margin:0 0 14px}.kpis{display:flex;gap:18px;flex-wrap:wrap}.kpi b{display:block;font-size:22px}
table{border-collapse:collapse;width:100%}th,td{text-align:left;padding:4px 8px;border-bottom:1px solid #eef0f2;vertical-align:top}
th{font-weight:600;color:#5c636a;font-size:12px}th.n,td.n{text-align:right;font-variant-numeric:tabular-nums}
.st{display:inline-block;padding:0 6px;border-radius:9px;border:1px solid var(--c);color:var(--c);font-size:12px;white-space:nowrap}
.note{color:#5c636a;font-size:12px;margin-top:18px}img.plan{max-width:100%;border:1px solid #dee2e6;margin:6px 0}
@media print{body{margin:12mm}h2{break-after:avoid}tr{break-inside:avoid}}
.cv-host{position:relative;max-width:760px}details.data summary{cursor:pointer;color:#5c636a;font-size:12px;margin:4px 0}
${CURVE_CSS}
</style></head><body>
<h1>${esc(project)} — progress report</h1>
<p class="sub">${day(now)} · ${r.capturedAreas} of ${r.totalAreas} areas captured · BuildVision Progress mode</p>
<div class="kpis">
<div class="kpi"><b>${pctCell(r.overall)}</b>average of captured areas</div>
<div class="kpi"><b>${r.counts.behind + r.counts.not_started_late}</b>behind</div>
<div class="kpi"><b>${r.counts.at_risk}</b>at risk</div>
<div class="kpi"><b>${r.areas.filter(a => a.stale).length}</b>not captured in ${STALE_DAYS}+ days</div>
</div>
${curve?.points.length >= 2 ? curveHtml(curve) : ''}
${r.attention.length ? `<h2>Needs attention</h2><table><tr><th>Area</th><th>Floor</th><th>Status</th><th>Why</th></tr>
${r.attention.map(a => `<tr><td>${esc(a.name)}</td><td>${esc(a.floor)}</td><td>${statusCell(a.status)}</td><td>${esc(a.reason)}</td></tr>`).join('\n')}</table>` : ''}
<h2>By floor</h2><table><tr><th>Floor</th><th class="n">Areas</th><th class="n">Captured</th><th class="n">Complete (avg)</th><th class="n">Behind</th><th class="n">At risk</th><th class="n">Stale</th></tr>
${r.floors.map(f => `<tr><td>${esc(f.floor)}</td><td class="n">${f.areas}</td><td class="n">${f.captured}</td><td class="n">${pctCell(f.pct)}</td><td class="n">${f.behind}</td><td class="n">${f.atRisk}</td><td class="n">${f.stale}</td></tr>`).join('\n')}</table>
${r.trades.length ? `<h2>By trade</h2><table><tr><th>Trade</th><th class="n">Areas read</th><th class="n">Complete (avg)</th><th class="n">Areas at 100%</th></tr>
${r.trades.map(t => `<tr><td>${esc(t.trade)}</td><td class="n">${t.areas}</td><td class="n">${pctCell(t.pct)}</td><td class="n">${t.complete}</td></tr>`).join('\n')}</table>` : ''}
<h2>All areas</h2><table><tr><th>Area</th><th>Floor</th><th>Status</th><th class="n">Actual</th><th class="n">Planned</th><th class="n">Days behind</th><th>Last capture</th><th>Projected finish</th><th>Planned finish</th></tr>
${rows.map(a => `<tr><td>${esc(a.name)}</td><td>${esc(a.floor)}</td><td>${statusCell(a.status)}</td><td class="n">${pctCell(a.actual)}</td><td class="n">${pctCell(a.planned)}</td><td class="n">${a.daysBehind ?? '—'}</td><td>${day(a.lastCapture)}${a.stale ? ' (stale)' : ''}</td><td>${day(a.projectedFinish)}</td><td>${day(a.plannedFinish)}</td></tr>`).join('\n')}</table>
${quantities?.perArea.length ? quantitiesHtml(quantities) : ''}
${facades.length ? facadesHtml(facades) : ''}
${planImages.map(p => `<h2>${esc(p.name)}</h2><img class="plan" src="${esc(p.src)}" alt="${esc(p.name)} with area pins">`).join('\n')}
<p class="note">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.</p>
</body></html>`;
}
const sf = n => `${Math.round(n).toLocaleString('en-US')} sf`;
function quantitiesHtml(q) {
const row = (label, part, whole) => `<tr><td>${esc(label)}</td><td class="n">${sf(part)}</td><td class="n">${whole > 0 ? `${Math.round((part / whole) * 100)}%` : '—'}</td></tr>`;
return `<h2>Quantities (from the scaled floor plan)</h2>
<table><tr><th>Walls (${sf(q.walls.total)} at ${q.ceilingHeightFt} ft ceilings)</th><th class="n">At or past</th><th class="n">Share</th></tr>
${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)}</table>
<table style="margin-top:8px"><tr><th>Ceilings (${sf(q.ceilings.total)})</th><th class="n">At or past</th><th class="n">Share</th></tr>
${q.ceilings.milestones.map(m => row(m.label, m.sf, q.ceilings.total)).join('\n')}</table>
<table style="margin-top:8px"><tr><th>Area</th><th class="n">Floor</th><th class="n">Walls</th><th>Wall stage</th><th>Ceiling stage</th></tr>
${q.perArea.map(a => `<tr><td>${esc(a.name)}</td><td class="n">${sf(a.floorSf)}</td><td class="n">${sf(a.wallSf)}</td><td>${esc(a.wallStage ? a.wallStage.replace(/^\w+_/, '').replace(/_/g, ' ') : '—')}</td><td>${esc(a.ceilingStage ? a.ceilingStage.replace(/^\w+_/, '').replace(/_/g, ' ') : '—')}</td></tr>`).join('\n')}</table>
${q.unmeasured.length ? `<p class="note">Not measured (no scaled plan, or no room found around the pin): ${esc(q.unmeasured.join(', '))}.</p>` : ''}`;
}
/** 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 `<h2>Facade (by elevation)</h2><table><tr><th>Elevation</th><th>Captured</th><th class="n">Enclosed</th><th class="n">Partly</th><th class="n">Open</th><th class="n">Closed</th><th>Floors (bottom → top)</th><th>Projection</th></tr>
${facades.map(f => `<tr><td>${esc(f.name)}</td><td>${day(f.time)}</td><td class="n">${f.summary.enclosed}/${f.summary.floors}</td><td class="n">${f.summary.partial}</td><td class="n">${f.summary.open}</td><td class="n">${f.summary.pct ?? '—'}%</td>
<td>${[...f.floors].sort((a, b) => a.floor - b.floor).map(fl => `<span title="Floor ${fl.floor}: ${esc(fl.status)}" style="display:inline-block;width:10px;height:14px;margin-right:1px;background:${colour[fl.status] || '#868e96'}"></span>`).join('')}</td>
<td>${esc(f.forecast || '—')}</td></tr>`).join('\n')}</table>`;
}
// ---------- 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 => `<line x1="${L}" x2="${L + w}" y1="${Y(g)}" y2="${Y(g)}" class="cv-grid"/><text x="${L - 6}" y="${Y(g) + 3.5}" class="cv-axis" text-anchor="end">${g}%</text>`).join('');
const ticks = [tMin, tMin + (tMax - tMin) / 2, tMax].map((t, i) => `<text x="${X(t)}" y="${height - 6}" class="cv-axis" text-anchor="${['start', 'middle', 'end'][i]}">${fmtDay(t)}</text>`).join('');
const today = curve.now >= tMin && curve.now <= tMax ? `<line x1="${X(curve.now)}" x2="${X(curve.now)}" y1="${T}" y2="${T + h}" class="cv-today"/><text x="${X(curve.now) + 3}" y="${T + 9}" class="cv-axis">today</text>` : '';
// 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 ? `<line x1="${X(p.t) + 6}" x2="${L + w + 6}" y1="${Y(p[key])}" y2="${y}" class="cv-leader"/>` : ''}<text x="${L + w + 10}" y="${y + 4}" class="cv-label">${name} ${Math.round(p[key])}%</text>`;
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 `<rect x="${x0.toFixed(1)}" y="${T}" width="${Math.max(1, x1 - x0).toFixed(1)}" height="${h}" class="cv-hit" data-x="${X(p.t).toFixed(1)}" data-date="${fmtDay(p.t)}" data-planned="${p.planned}" data-actual="${p.actual ?? ''}" data-yp="${Y(p.planned).toFixed(1)}" data-ya="${p.actual == null ? '' : Y(p.actual).toFixed(1)}"/>`;
}).join('');
return `<svg class="cv" viewBox="0 0 ${width} ${height}" width="100%" role="img" aria-label="Planned vs actual % complete over time: planned ${Math.round(lp.planned)}%${la ? `, actual ${Math.round(la.actual)}% at ${fmtDay(la.t)}` : ''}">
${grid}${ticks}${today}
<path d="${path('planned')}" class="cv-planned"/><path d="${path('actual')}" class="cv-actual"/>
<circle cx="${X(lp.t)}" cy="${Y(lp.planned)}" r="4" class="cv-planned-dot"/>${la ? `<circle cx="${X(la.t)}" cy="${Y(la.actual)}" r="4" class="cv-actual-dot"/>` : ''}
${label(lp, 'planned', yp, 'Planned')}${la ? label(la, 'actual', ya, 'Actual') : ''}
<line class="cv-cross" x1="0" x2="0" y1="${T}" y2="${T + h}" visibility="hidden"/>
<circle class="cv-hover cv-planned-dot" r="4" visibility="hidden"/><circle class="cv-hover cv-actual-dot" r="4" visibility="hidden"/>
<g class="cv-hits">${cols}</g></svg>`;
}
/** 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 `<h2>Planned vs actual (${curve.areas} scheduled area${curve.areas === 1 ? '' : 's'})</h2>
<div class="cv-host cv-wrap">${curveLegendHtml(curve)}${curveSvg(curve)}</div>
<details class="data"><summary>Show the data</summary><table><tr><th>Date</th><th class="n">Planned</th><th class="n">Actual</th></tr>
${curve.points.map(p => `<tr><td>${day2(p.t)}</td><td class="n">${Math.round(p.planned)}%</td><td class="n">${p.actual == null ? '—' : `${Math.round(p.actual)}%`}</td></tr>`).join('\n')}</table></details>
<script>(${attachCurveHover.toString()})(document.querySelector('.cv-host'));</script>`;
}
/** 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 ? `<p class="cv-headline">${today.text}</p>` : ''}<div class="cv-legend"><span><i style="background:var(--series-1)"></i>Planned (straight line between each area's planned dates)</span><span><i style="background:var(--series-2)"></i>Actual (each area's latest capture)</span></div>`;
}