Image-to-Text
English
custom
ocr
document-ai
information-extraction
nameplate
nameplate-ocr
equipment-nameplate
data-center
data-centre
datacenter
mission-critical
mep
electrical
equipment-schedule
schedule-verification
asset-register
commissioning
quality-assurance
ups
pdu
switchgear
generator
ats
transformer
busway
battery
crah
crac
chiller
cooling
construction
aec
edge-ai
on-device
privacy-preserving
tesseract
browser
File size: 35,682 Bytes
94e56dd | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 | // 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 => `<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>`;
}
|