constructelligence commited on
Commit
94e56dd
·
verified ·
1 Parent(s): 5e2d1eb

Upload folder using huggingface_hub

Browse files
Files changed (2) hide show
  1. README.md +1 -0
  2. progress-project.js +535 -0
README.md CHANGED
@@ -240,6 +240,7 @@ to keep OCR confidence and the plate line each value came from.
240
  |---|---|
241
  | `nameplate-model.js` | the extractor, matcher and register builder (ES module) |
242
  | `schedule-import.js` | the CSV schedule reader it depends on |
 
243
  | `sample_schedule.csv` | a 10-row data-centre equipment schedule |
244
  | `sample_plates.json` | two sample plates: one that verifies, one flagged on model and voltage |
245
  | `eval/` | the OCR benchmark harness output |
 
240
  |---|---|
241
  | `nameplate-model.js` | the extractor, matcher and register builder (ES module) |
242
  | `schedule-import.js` | the CSV schedule reader it depends on |
243
+ | `progress-project.js` | a helper the schedule reader imports |
244
  | `sample_schedule.csv` | a 10-row data-centre equipment schedule |
245
  | `sample_plates.json` | two sample plates: one that verifies, one flagged on model and voltage |
246
  | `eval/` | the OCR benchmark harness output |
progress-project.js ADDED
@@ -0,0 +1,535 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Project-level progress for Progress mode: the layer Buildots puts on top of
2
+ // per-area stage reads — where each area sits on the floor plan, whether it is
3
+ // ahead of or behind its planned dates, which areas nobody has walked lately,
4
+ // and one report across every area and trade.
5
+ //
6
+ // Progress mode already stores, per named area, a plan (what the area should
7
+ // contain) and dated captures (overall % and per-trade items, from
8
+ // rollupProgress). This module only reads that store plus three optional
9
+ // fields per area, all entered by the user — BuildVision has no BIM and no
10
+ // schedule import:
11
+ //
12
+ // area.schedule = { start, finish, trades?: { [itemKey]: { start, finish } } } (ms timestamps)
13
+ // area.floor = 'L3' (else parsed from the area name: "L3 east corridor")
14
+ // area.pin = { plan, x, y } (plan id, x/y as 0..1 of the plan image)
15
+ //
16
+ // and store.plans = { [id]: { name, image } } for the floor-plan images.
17
+ //
18
+ // Planned progress is linear between an area's start and finish — the same
19
+ // straight-line reading a Gantt bar gives, not a resource-loaded curve — and
20
+ // the UI says so. Everything here is a pure function (tests/progress-project.test.mjs).
21
+
22
+ const DAY = 86400000;
23
+ export const ON_TRACK_POINTS = 5; // |actual − planned| within this many points reads as on track
24
+ export const STALE_DAYS = 7; // an unfinished area not captured in this long is flagged
25
+ export const SLIP_TOLERANCE_DAYS = 2;
26
+
27
+ export const STATUS = {
28
+ complete: { label: 'Complete', color: '#2f9e44', rank: 0 },
29
+ ahead: { label: 'Ahead', color: '#1c7ed6', rank: 1 },
30
+ on_track: { label: 'On track', color: '#74b816', rank: 2 },
31
+ at_risk: { label: 'At risk', color: '#f59f00', rank: 4 },
32
+ behind: { label: 'Behind', color: '#e03131', rank: 5 },
33
+ not_started_late: { label: 'Should have started', color: '#c2255c', rank: 6 },
34
+ not_captured: { label: 'Not captured', color: '#868e96', rank: 3 },
35
+ unscheduled: { label: 'No schedule', color: '#868e96', rank: 3 },
36
+ };
37
+
38
+ const latestCapture = rec => {
39
+ const caps = (rec?.captures || []).filter(c => Number.isFinite(c.time));
40
+ return caps.length ? caps.reduce((a, b) => (b.time > a.time ? b : a)) : null;
41
+ };
42
+
43
+ /** Planned % complete at time t for a { start, finish } window: 0 before, 100 after, linear between. */
44
+ export function plannedPctAt(window, t) {
45
+ if (!window || !Number.isFinite(window.start) || !Number.isFinite(window.finish) || window.finish <= window.start) return null;
46
+ if (t <= window.start) return 0;
47
+ if (t >= window.finish) return 100;
48
+ return Math.round(((t - window.start) / (window.finish - window.start)) * 100);
49
+ }
50
+
51
+ /**
52
+ * Least-squares production rate (points/day) over captures; null without two captures a day apart.
53
+ * Same fit forecastCompletion uses, repeated here so this module stands alone.
54
+ */
55
+ function productionRate(captures) {
56
+ const pts = (captures || []).filter(c => c.overall != null && Number.isFinite(c.time)).map(c => [c.time / DAY, c.overall]);
57
+ if (pts.length < 2) return null;
58
+ const xs = pts.map(p => p[0]);
59
+ if (Math.max(...xs) - Math.min(...xs) < 1) return null;
60
+ const mx = xs.reduce((s, x) => s + x, 0) / pts.length, my = pts.reduce((s, p) => s + p[1], 0) / pts.length;
61
+ const sxx = pts.reduce((s, p) => s + (p[0] - mx) ** 2, 0);
62
+ return pts.reduce((s, p) => s + (p[0] - mx) * (p[1] - my), 0) / sxx;
63
+ }
64
+
65
+ /**
66
+ * Planned vs actual for one window and one actual reading taken at `at`.
67
+ * daysBehind is schedule time: how long ago the plan expected today's actual %
68
+ * (positive = behind, negative = ahead) — the number a superintendent quotes.
69
+ */
70
+ function varianceOf(window, actual, at) {
71
+ const planned = plannedPctAt(window, at);
72
+ if (planned == null || actual == null) return { planned, actual, variancePts: null, daysBehind: null };
73
+ const whenPlanned = window.start + (Math.min(100, actual) / 100) * (window.finish - window.start);
74
+ return { planned, actual, variancePts: actual - planned, daysBehind: Math.round((at - whenPlanned) / DAY) };
75
+ }
76
+
77
+ /**
78
+ * Where one area stands against its schedule, from its latest capture.
79
+ * Status precedence: complete > (no schedule) > should-have-started > behind > at risk > ahead > on track.
80
+ * "At risk" = within the on-track band today but the production rate lands
81
+ * the finish more than SLIP_TOLERANCE_DAYS after the planned finish.
82
+ */
83
+ export function areaStatus(rec, now = Date.now(), { staleDays = STALE_DAYS } = {}) {
84
+ const last = latestCapture(rec);
85
+ const schedule = rec?.schedule;
86
+ const actual = last?.overall ?? null;
87
+ const lastCaptureDays = last ? Math.floor((now - last.time) / DAY) : null;
88
+ const complete = actual != null && actual >= 100;
89
+ const stale = !complete && last != null && lastCaptureDays > staleDays;
90
+ const base = { actual, lastCapture: last?.time ?? null, lastCaptureDays, stale, captures: (rec?.captures || []).length, items: last?.items || [] };
91
+
92
+ const rate = productionRate(rec?.captures);
93
+ let projectedFinish = null;
94
+ if (complete) projectedFinish = last.time;
95
+ else if (rate > 0.05 && last) projectedFinish = Math.max(now, last.time + ((100 - actual) / rate) * DAY);
96
+ const withSchedule = Number.isFinite(schedule?.start) && Number.isFinite(schedule?.finish) && schedule.finish > schedule.start;
97
+ const plannedFinish = withSchedule ? schedule.finish : null;
98
+ const slipDays = projectedFinish != null && plannedFinish != null ? Math.round((projectedFinish - plannedFinish) / DAY) : null;
99
+ const trades = tradeStatus(rec, last);
100
+
101
+ if (complete) return { ...base, status: 'complete', planned: withSchedule ? plannedPctAt(schedule, now) : null,
102
+ variancePts: null, daysBehind: null, rate, projectedFinish, plannedFinish, slipDays, trades };
103
+ if (!withSchedule) {
104
+ return { ...base, status: last ? 'unscheduled' : 'not_captured', planned: null, variancePts: null, daysBehind: null,
105
+ rate, projectedFinish, plannedFinish: null, slipDays: null, trades };
106
+ }
107
+ if (!last) {
108
+ const planned = plannedPctAt(schedule, now);
109
+ return { ...base, status: now > schedule.start ? 'not_started_late' : 'not_captured', planned, variancePts: null,
110
+ daysBehind: now > schedule.start ? Math.round((now - schedule.start) / DAY) : null, rate, projectedFinish, plannedFinish, slipDays, trades };
111
+ }
112
+ // Judge the latest capture against the plan *at that capture's date*: a
113
+ // week-old 40% is not "behind" today's 70% plan by 30 points, it is stale.
114
+ const v = varianceOf(schedule, actual, last.time);
115
+ let status;
116
+ if (v.variancePts < -ON_TRACK_POINTS) status = 'behind';
117
+ else if (slipDays != null && slipDays > SLIP_TOLERANCE_DAYS) status = 'at_risk';
118
+ else if (rate != null && !(rate > 0.05) && plannedPctAt(schedule, now) > actual + ON_TRACK_POINTS) status = 'at_risk'; // stalled
119
+ else if (v.variancePts > ON_TRACK_POINTS) status = 'ahead';
120
+ else status = 'on_track';
121
+ return { ...base, status, ...v, rate, projectedFinish, plannedFinish, slipDays, trades };
122
+ }
123
+
124
+ /** Per-trade variance for trades that have their own planned window. */
125
+ function tradeStatus(rec, last) {
126
+ const windows = rec?.schedule?.trades || {};
127
+ return (last?.items || []).filter(i => windows[i.key]).map(i => {
128
+ const v = varianceOf(windows[i.key], i.pct ?? null, last.time);
129
+ const status = i.pct >= 100 ? 'complete' : v.variancePts == null ? 'not_captured'
130
+ : v.variancePts < -ON_TRACK_POINTS ? 'behind' : v.variancePts > ON_TRACK_POINTS ? 'ahead' : 'on_track';
131
+ return { key: i.key, trade: i.trade, status, ...v };
132
+ });
133
+ }
134
+
135
+ /** "L3", "Level 3", "Floor 03", "3F", "B1", "Basement 2" at the start of an area name → a floor key. */
136
+ export function floorOf(name, rec) {
137
+ if (rec?.floor) return String(rec.floor).trim();
138
+ const s = String(name || '').trim();
139
+ let m = s.match(/^(?:l|lvl|level|fl|floor)\s*-?\s*0*(\d+)\b/i) || s.match(/^0*(\d+)\s*(?:f|fl)\b/i);
140
+ if (m) return `L${Number(m[1])}`;
141
+ m = s.match(/^(?:b|basement)\s*-?\s*0*(\d+)\b/i);
142
+ if (m) return `B${Number(m[1])}`;
143
+ if (/^(?:roof|rf)\b/i.test(s)) return 'Roof';
144
+ if (/^(?:ground|gf|g)\b/i.test(s)) return 'L0';
145
+ return 'Unassigned';
146
+ }
147
+
148
+ const floorOrder = f => (f === 'Unassigned' ? 1e6 : f === 'Roof' ? 1e5 : f[0] === 'B' ? -Number(f.slice(1)) : Number(f.slice(1)) || 0);
149
+
150
+ /**
151
+ * Every area, rolled up: per-area status, per-floor and per-trade averages from
152
+ * each area's latest capture, and an attention list (behind / at risk / late
153
+ * starts / stale) ordered worst first. Averages count captured areas only and
154
+ * say how many they cover, so one captured room is never presented as the floor.
155
+ */
156
+ export function projectRollup(store, now = Date.now(), opts = {}) {
157
+ const areas = Object.entries(store?.areas || {}).map(([name, rec]) => ({
158
+ name, floor: floorOf(name, rec), pin: rec.pin || null, ...areaStatus(rec, now, opts),
159
+ }));
160
+ const captured = areas.filter(a => a.actual != null);
161
+ const mean = xs => (xs.length ? Math.round(xs.reduce((s, x) => s + x, 0) / xs.length) : null);
162
+
163
+ const floors = {};
164
+ for (const a of areas) {
165
+ const f = (floors[a.floor] ||= { floor: a.floor, areas: 0, captured: 0, pcts: [], behind: 0, atRisk: 0, stale: 0 });
166
+ f.areas += 1;
167
+ if (a.actual != null) { f.captured += 1; f.pcts.push(a.actual); }
168
+ if (a.status === 'behind' || a.status === 'not_started_late') f.behind += 1;
169
+ if (a.status === 'at_risk') f.atRisk += 1;
170
+ if (a.stale) f.stale += 1;
171
+ }
172
+ const floorList = Object.values(floors).map(({ pcts, ...f }) => ({ ...f, pct: mean(pcts) }))
173
+ .sort((a, b) => floorOrder(a.floor) - floorOrder(b.floor));
174
+
175
+ const tradeAcc = {};
176
+ for (const [name, rec] of Object.entries(store?.areas || {})) {
177
+ const last = latestCapture(rec);
178
+ (last?.items || []).forEach(i => {
179
+ if (i.pct == null) return;
180
+ const t = (tradeAcc[i.key] ||= { key: i.key, trade: i.trade, pcts: [], areas: [] });
181
+ t.pcts.push(i.pct); t.areas.push(name);
182
+ });
183
+ }
184
+ const trades = Object.values(tradeAcc).map(t => ({ key: t.key, trade: t.trade, pct: mean(t.pcts), areas: t.areas.length,
185
+ complete: t.pcts.filter(p => p >= 100).length }));
186
+
187
+ const counts = Object.fromEntries(Object.keys(STATUS).map(k => [k, 0]));
188
+ areas.forEach(a => { counts[a.status] += 1; });
189
+ const attention = areas
190
+ .filter(a => ['behind', 'at_risk', 'not_started_late'].includes(a.status) || a.stale || a.trades?.some(t => t.status === 'behind'))
191
+ .map(a => ({ name: a.name, floor: a.floor, status: a.status, stale: a.stale, daysBehind: a.daysBehind, slipDays: a.slipDays,
192
+ lastCaptureDays: a.lastCaptureDays, reason: attentionReason(a) }))
193
+ .sort((a, b) => STATUS[b.status].rank - STATUS[a.status].rank || (b.daysBehind ?? 0) - (a.daysBehind ?? 0)
194
+ || (b.lastCaptureDays ?? 0) - (a.lastCaptureDays ?? 0));
195
+
196
+ return { areas, floors: floorList, trades, counts, attention, overall: mean(captured.map(a => a.actual)),
197
+ capturedAreas: captured.length, totalAreas: areas.length };
198
+ }
199
+
200
+ function attentionReason(a) {
201
+ const parts = [];
202
+ if (a.status === 'behind') parts.push(`${a.actual}% vs ${a.planned}% planned at last capture (${a.daysBehind} day${a.daysBehind === 1 ? '' : 's'} behind)`);
203
+ if (a.status === 'not_started_late') parts.push(`planned to start ${a.daysBehind} day${a.daysBehind === 1 ? '' : 's'} ago, never captured`);
204
+ 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');
205
+ const lateTrades = (a.trades || []).filter(t => t.status === 'behind');
206
+ 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(', ')}`);
207
+ if (a.stale) parts.push(`last captured ${a.lastCaptureDays} days ago`);
208
+ return parts.join('; ');
209
+ }
210
+
211
+ // ---------- quantities from a scaled floor plan ----------
212
+ //
213
+ // With a plan scale (feet per plan pixel, from two clicked points and a known
214
+ // distance) and the room each area is pinned in, a stage read becomes square
215
+ // feet: floor area from the room polygon, wall area from its perimeter × the
216
+ // ceiling height. Stage → how far along the trade sequence the surface is.
217
+ // This is the room outline as drawn (interior partitions inside a room, and
218
+ // openings, are not deducted) — a progress quantity, not a takeoff.
219
+
220
+ const STAGE_PCT = { wall_framing: 25, wall_board: 50, wall_taped: 75, wall_finished: 100,
221
+ ceiling_open: 33, ceiling_grid: 67, ceiling_finished: 100 };
222
+ export const WALL_MILESTONES = [['Framed', 25], ['Board hung', 50], ['Taped & mudded', 75], ['Finished', 100]];
223
+ export const CEILING_MILESTONES = [['Grid hung', 67], ['Closed', 100]];
224
+
225
+ /** Shoelace area and perimeter of a 0..1 polygon on a W×H plan, in plan pixels. */
226
+ export function polygonMetrics(poly, W, H) {
227
+ let a = 0, per = 0;
228
+ for (let i = 0; i < poly.length; i++) {
229
+ const [x1, y1] = poly[i], [x2, y2] = poly[(i + 1) % poly.length];
230
+ a += x1 * W * y2 * H - x2 * W * y1 * H;
231
+ per += Math.hypot((x2 - x1) * W, (y2 - y1) * H);
232
+ }
233
+ return { area: Math.abs(a) / 2, perimeter: per };
234
+ }
235
+
236
+ /**
237
+ * Square feet per pinned, measurable area and totals by milestone: how much
238
+ * wall has reached "board hung" or beyond, how much ceiling is closed, etc.
239
+ * Areas without a scaled plan, a detected room around the pin, or a reading
240
+ * are listed as unmeasured rather than guessed.
241
+ */
242
+ export function areaQuantities(rollup, plans = {}, { ceilingHeightFt = 9 } = {}) {
243
+ const perArea = [], unmeasured = [];
244
+ for (const a of rollup.areas) {
245
+ const plan = a.pin && plans[a.pin.plan];
246
+ const room = plan?.rooms && roomAt(plan.rooms, a.pin.x, a.pin.y);
247
+ if (!plan?.scale?.ftPerPx || !plan.size || !room) { unmeasured.push(a.name); continue; }
248
+ const m = polygonMetrics(room.poly, plan.size.w, plan.size.h);
249
+ const f = plan.scale.ftPerPx;
250
+ const wall = a.items.find(i => i.key === 'wall'), ceiling = a.items.find(i => i.key === 'ceiling');
251
+ perArea.push({ name: a.name, floorSf: Math.round(m.area * f * f), wallSf: Math.round(m.perimeter * f * ceilingHeightFt),
252
+ wallStage: wall?.stage || null, ceilingStage: ceiling?.stage || null });
253
+ }
254
+ const sumBy = (key, stageKey, milestones) => {
255
+ const total = perArea.reduce((s, q) => s + q[key], 0);
256
+ const read = perArea.filter(q => q[stageKey]).reduce((s, q) => s + q[key], 0);
257
+ return { total, read, milestones: milestones.map(([label, pct]) => ({ label, sf: perArea
258
+ .filter(q => q[stageKey] && STAGE_PCT[q[stageKey]] >= pct).reduce((s, q) => s + q[key], 0) })) };
259
+ };
260
+ return { perArea, unmeasured, ceilingHeightFt,
261
+ walls: sumBy('wallSf', 'wallStage', WALL_MILESTONES), ceilings: sumBy('floorSf', 'ceilingStage', CEILING_MILESTONES) };
262
+ }
263
+
264
+ /** Colour ramp for a % complete (grey for no reading) — the plan heatmap. */
265
+ export function pctColor(pct) {
266
+ if (pct == null) return '#868e96';
267
+ const stops = [[0, [224, 49, 49]], [50, [245, 159, 0]], [100, [47, 158, 68]]];
268
+ const p = Math.max(0, Math.min(100, pct));
269
+ const i = p <= 50 ? 0 : 1;
270
+ const [p0, c0] = stops[i], [p1, c1] = stops[i + 1];
271
+ const t = (p - p0) / (p1 - p0);
272
+ return `#${c0.map((c, k) => Math.round(c + (c1[k] - c) * t).toString(16).padStart(2, '0')).join('')}`;
273
+ }
274
+
275
+ /** Pins for one floor-plan image, coloured by % complete or by schedule status. */
276
+ export function pinsForPlan(rollup, planId, colorBy = 'pct') {
277
+ return rollup.areas.filter(a => a.pin && a.pin.plan === planId && Number.isFinite(a.pin.x) && Number.isFinite(a.pin.y))
278
+ .map(a => ({ name: a.name, x: a.pin.x, y: a.pin.y, pct: a.actual, status: a.status, stale: a.stale,
279
+ color: colorBy === 'status' ? STATUS[a.status].color : pctColor(a.actual) }));
280
+ }
281
+
282
+ /** The pin nearest (x, y) within `radius` (all in the same units), or null. */
283
+ export function nearestPin(pins, x, y, radius) {
284
+ let best = null, bestD = radius;
285
+ for (const p of pins) {
286
+ const d = Math.hypot(p.x - x, p.y - y);
287
+ if (d <= bestD) { best = p; bestD = d; }
288
+ }
289
+ return best;
290
+ }
291
+
292
+ /** Even-odd point-in-polygon; poly is [[x, y], …] in the same units as (x, y). */
293
+ export function pointInPolygon(poly, x, y) {
294
+ let inside = false;
295
+ for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
296
+ const [xi, yi] = poly[i], [xj, yj] = poly[j];
297
+ if ((yi > y) !== (yj > y) && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside;
298
+ }
299
+ return inside;
300
+ }
301
+
302
+ /** Smallest detected room containing (x, y) — rooms nest when a closet sits inside a larger outline. */
303
+ export function roomAt(rooms, x, y) {
304
+ let best = null;
305
+ for (const room of rooms || []) {
306
+ if (pointInPolygon(room.poly, x, y) && (!best || room.area < best.area)) best = room;
307
+ }
308
+ return best;
309
+ }
310
+
311
+ /**
312
+ * Rooms to fill on a plan: each pin claims the room it sits in and paints it
313
+ * its colour. Two areas pinned in one room share it (the later pin's colour,
314
+ * both names listed) rather than one silently hiding the other.
315
+ */
316
+ export function roomFills(rooms, pins) {
317
+ const fills = new Map();
318
+ for (const pin of pins) {
319
+ const room = roomAt(rooms, pin.x, pin.y);
320
+ if (!room) continue;
321
+ const f = fills.get(room) || { poly: room.poly, names: [], color: pin.color, pct: pin.pct };
322
+ f.names.push(pin.name);
323
+ f.color = pin.color; f.pct = pin.pct;
324
+ fills.set(room, f);
325
+ }
326
+ return [...fills.values()];
327
+ }
328
+
329
+ const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
330
+ const day = t => (Number.isFinite(t) ? new Date(t).toISOString().slice(0, 10) : '—');
331
+
332
+ /**
333
+ * A self-contained, printable HTML progress report (the browser's Print → PDF
334
+ * makes the PDF). Every number comes from projectRollup; nothing is restated by hand.
335
+ */
336
+ export function progressReportHtml(rollup, { project = 'Project', now = Date.now(), planImages = [], quantities = null, facades = [], curve = null } = {}) {
337
+ const r = rollup;
338
+ const pctCell = p => (p == null ? '—' : `${p}%`);
339
+ const statusCell = s => `<span class="st" style="--c:${STATUS[s].color}">${STATUS[s].label}</span>`;
340
+ const rows = [...r.areas].sort((a, b) => floorOrder(a.floor) - floorOrder(b.floor) || a.name.localeCompare(b.name));
341
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${esc(project)} — progress ${day(now)}</title>
342
+ <style>
343
+ body{font:13px/1.45 system-ui,-apple-system,Segoe UI,sans-serif;color:#1a1d21;margin:28px;max-width:1000px}
344
+ h1{font-size:20px;margin:0 0 2px}h2{font-size:15px;margin:22px 0 6px;border-bottom:1px solid #dee2e6;padding-bottom:3px}
345
+ .sub{color:#5c636a;margin:0 0 14px}.kpis{display:flex;gap:18px;flex-wrap:wrap}.kpi b{display:block;font-size:22px}
346
+ table{border-collapse:collapse;width:100%}th,td{text-align:left;padding:4px 8px;border-bottom:1px solid #eef0f2;vertical-align:top}
347
+ th{font-weight:600;color:#5c636a;font-size:12px}th.n,td.n{text-align:right;font-variant-numeric:tabular-nums}
348
+ .st{display:inline-block;padding:0 6px;border-radius:9px;border:1px solid var(--c);color:var(--c);font-size:12px;white-space:nowrap}
349
+ .note{color:#5c636a;font-size:12px;margin-top:18px}img.plan{max-width:100%;border:1px solid #dee2e6;margin:6px 0}
350
+ @media print{body{margin:12mm}h2{break-after:avoid}tr{break-inside:avoid}}
351
+ .cv-host{position:relative;max-width:760px}details.data summary{cursor:pointer;color:#5c636a;font-size:12px;margin:4px 0}
352
+ ${CURVE_CSS}
353
+ </style></head><body>
354
+ <h1>${esc(project)} — progress report</h1>
355
+ <p class="sub">${day(now)} · ${r.capturedAreas} of ${r.totalAreas} areas captured · BuildVision Progress mode</p>
356
+ <div class="kpis">
357
+ <div class="kpi"><b>${pctCell(r.overall)}</b>average of captured areas</div>
358
+ <div class="kpi"><b>${r.counts.behind + r.counts.not_started_late}</b>behind</div>
359
+ <div class="kpi"><b>${r.counts.at_risk}</b>at risk</div>
360
+ <div class="kpi"><b>${r.areas.filter(a => a.stale).length}</b>not captured in ${STALE_DAYS}+ days</div>
361
+ </div>
362
+ ${curve?.points.length >= 2 ? curveHtml(curve) : ''}
363
+ ${r.attention.length ? `<h2>Needs attention</h2><table><tr><th>Area</th><th>Floor</th><th>Status</th><th>Why</th></tr>
364
+ ${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>` : ''}
365
+ <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>
366
+ ${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>
367
+ ${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>
368
+ ${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>` : ''}
369
+ <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>
370
+ ${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>
371
+ ${quantities?.perArea.length ? quantitiesHtml(quantities) : ''}
372
+ ${facades.length ? facadesHtml(facades) : ''}
373
+ ${planImages.map(p => `<h2>${esc(p.name)}</h2><img class="plan" src="${esc(p.src)}" alt="${esc(p.name)} with area pins">`).join('\n')}
374
+ <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
375
+ 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
376
+ before the capture date the plan expected the % the capture read.</p>
377
+ </body></html>`;
378
+ }
379
+
380
+ const sf = n => `${Math.round(n).toLocaleString('en-US')} sf`;
381
+
382
+ function quantitiesHtml(q) {
383
+ 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>`;
384
+ return `<h2>Quantities (from the scaled floor plan)</h2>
385
+ <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>
386
+ ${q.walls.milestones.map(m => row(m.label, m.sf, q.walls.total)).join('\n')}
387
+ ${row('Stage not read', q.walls.total - q.walls.read, q.walls.total)}</table>
388
+ <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>
389
+ ${q.ceilings.milestones.map(m => row(m.label, m.sf, q.ceilings.total)).join('\n')}</table>
390
+ <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>
391
+ ${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>
392
+ ${q.unmeasured.length ? `<p class="note">Not measured (no scaled plan, or no room found around the pin): ${esc(q.unmeasured.join(', '))}.</p>` : ''}`;
393
+ }
394
+
395
+ /** facades: [{ name, time, summary: { enclosed, partial, open, floors, pct, enclosedThrough }, floors: [{ floor, status, closure }], forecast }] */
396
+ function facadesHtml(facades) {
397
+ const colour = { enclosed: '#2f9e44', partial: '#f59f00', open: '#e03131', unknown: '#868e96' };
398
+ 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>
399
+ ${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>
400
+ <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>
401
+ <td>${esc(f.forecast || '—')}</td></tr>`).join('\n')}</table>`;
402
+ }
403
+
404
+ // ---------- planned vs actual over time (the project S-curve) ----------
405
+
406
+ /**
407
+ * Project % complete over time, planned against actual, across the areas that
408
+ * have planned dates (the only ones a plan exists for). Planned at t is the
409
+ * mean of each area's straight-line plan; actual at t is the mean of each
410
+ * area's latest capture on or before t, counting an area with no capture yet
411
+ * as 0 — so both lines describe the same set of areas. Actual stops at `now`.
412
+ */
413
+ export function projectCurve(store, now = Date.now(), { maxPoints = 60 } = {}) {
414
+ const areas = Object.values(store?.areas || {}).filter(r => Number.isFinite(r.schedule?.start) && Number.isFinite(r.schedule?.finish) && r.schedule.finish > r.schedule.start);
415
+ if (!areas.length) return { points: [], areas: 0 };
416
+ const caps = areas.flatMap(r => (r.captures || []).map(c => c.time)).filter(Number.isFinite);
417
+ const t0 = Math.min(...areas.map(r => r.schedule.start), ...caps);
418
+ const t1 = Math.max(...areas.map(r => r.schedule.finish), Math.min(now, Math.max(now, ...caps)));
419
+ const steps = Math.max(2, Math.min(maxPoints, Math.ceil((t1 - t0) / DAY) + 1));
420
+ 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);
421
+ const actualAt = (r, t) => {
422
+ let best = null;
423
+ for (const c of r.captures || []) if (c.time <= t && c.overall != null && (!best || c.time > best.time)) best = c;
424
+ return best ? best.overall : 0;
425
+ };
426
+ const mean = xs => Math.round((xs.reduce((s, x) => s + x, 0) / xs.length) * 10) / 10;
427
+ const points = times.map(t => ({ t, planned: mean(areas.map(r => plannedPctAt(r.schedule, t))),
428
+ actual: t <= now ? mean(areas.map(r => actualAt(r, t))) : null }));
429
+ return { points, areas: areas.length, now };
430
+ }
431
+
432
+ /**
433
+ * The curve as a self-contained SVG string: two 2px lines (planned, actual),
434
+ * end dots with a surface ring, a direct label at each line's end, hairline
435
+ * gridlines at 0/25/50/75/100, a "today" rule, and one invisible hit column per
436
+ * point carrying data-* for the hover script. Colours come from CSS custom
437
+ * properties (--series-1 planned, --series-2 actual, --grid, --text-2, --surface)
438
+ * so the page supplies light/dark values.
439
+ */
440
+ export function curveSvg(curve, { width = 640, height = 220 } = {}) {
441
+ const pts = curve.points;
442
+ if (pts.length < 2) return '';
443
+ const L = 40, R = 104, T = 10, B = 24, w = width - L - R, h = height - T - B;
444
+ const tMin = pts[0].t, tMax = pts[pts.length - 1].t;
445
+ const X = t => L + ((t - tMin) / Math.max(1, tMax - tMin)) * w, Y = p => T + h - (p / 100) * h;
446
+ 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('');
447
+ const lastOf = key => [...pts].reverse().find(p => p[key] != null);
448
+ const lp = lastOf('planned'), la = lastOf('actual');
449
+ const fmtDay = t => new Date(t).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
450
+ 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('');
451
+ 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('');
452
+ 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>` : '';
453
+ // Direct labels at the right edge; pushed apart only when they would overlap, with a leader to the line end.
454
+ let ya = la ? Y(la.actual) : null, yp = Y(lp.planned);
455
+ 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); }
456
+ // The line's own end dot is the label's key; a leader runs to the label only when it had to move.
457
+ 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>`;
458
+ const cols = pts.map((p, i) => {
459
+ 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;
460
+ 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)}"/>`;
461
+ }).join('');
462
+ 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)}` : ''}">
463
+ ${grid}${ticks}${today}
464
+ <path d="${path('planned')}" class="cv-planned"/><path d="${path('actual')}" class="cv-actual"/>
465
+ <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"/>` : ''}
466
+ ${label(lp, 'planned', yp, 'Planned')}${la ? label(la, 'actual', ya, 'Actual') : ''}
467
+ <line class="cv-cross" x1="0" x2="0" y1="${T}" y2="${T + h}" visibility="hidden"/>
468
+ <circle class="cv-hover cv-planned-dot" r="4" visibility="hidden"/><circle class="cv-hover cv-actual-dot" r="4" visibility="hidden"/>
469
+ <g class="cv-hits">${cols}</g></svg>`;
470
+ }
471
+
472
+ /** Styles for curveSvg (light + dark). Scoped under .cv. */
473
+ 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}
474
+ @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}}
475
+ :root[data-theme="dark"] :is(.cv,.cv-wrap){--series-1:#3987e5;--series-2:#d95926;--grid:#33332f;--text-2:#c3c2b7;--surface:#1a1a19}
476
+ .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}
477
+ .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)}
478
+ .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}
479
+ .cv-leader{stroke:var(--text-2);stroke-width:1;opacity:.35}.cv-label{fill:currentColor;font-weight:600}
480
+ .cv-cross{stroke:var(--text-2);stroke-width:1}.cv-hit{fill:transparent;cursor:crosshair}
481
+ .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}
482
+ .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}
483
+ .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)}`;
484
+
485
+ /**
486
+ * Hover for a curveSvg inside `host` (position:relative): crosshair, both
487
+ * dots at the hovered date, and a tooltip with the date and both values.
488
+ * Serialised into the report too (Function#toString), so it must stay self-contained.
489
+ */
490
+ export function attachCurveHover(host) {
491
+ const svg = host.querySelector('svg.cv');
492
+ if (!svg) return;
493
+ const tip = document.createElement('div');
494
+ tip.className = 'cv-tip'; tip.hidden = true;
495
+ host.append(tip);
496
+ const cross = svg.querySelector('.cv-cross'), [dp, da] = svg.querySelectorAll('.cv-hover');
497
+ const hide = () => { tip.hidden = true; [cross, dp, da].forEach(el => el.setAttribute('visibility', 'hidden')); };
498
+ svg.querySelectorAll('.cv-hit').forEach(r => r.addEventListener('pointermove', e => {
499
+ const d = r.dataset;
500
+ cross.setAttribute('x1', d.x); cross.setAttribute('x2', d.x); cross.setAttribute('visibility', 'visible');
501
+ dp.setAttribute('cx', d.x); dp.setAttribute('cy', d.yp); dp.setAttribute('visibility', 'visible');
502
+ if (d.ya) { da.setAttribute('cx', d.x); da.setAttribute('cy', d.ya); da.setAttribute('visibility', 'visible'); } else da.setAttribute('visibility', 'hidden');
503
+ tip.textContent = `${d.date} · planned ${Math.round(+d.planned)}%${d.actual ? ` · actual ${Math.round(+d.actual)}%` : ''}`;
504
+ tip.hidden = false;
505
+ const box = host.getBoundingClientRect();
506
+ tip.style.left = `${Math.min(box.width - tip.offsetWidth, Math.max(0, e.clientX - box.left + 10))}px`;
507
+ tip.style.top = `${Math.max(0, e.clientY - box.top - 30)}px`;
508
+ }));
509
+ svg.addEventListener('pointerleave', hide);
510
+ }
511
+
512
+ function curveHtml(curve) {
513
+ const day2 = t => new Date(t).toISOString().slice(0, 10);
514
+ return `<h2>Planned vs actual (${curve.areas} scheduled area${curve.areas === 1 ? '' : 's'})</h2>
515
+ <div class="cv-host cv-wrap">${curveLegendHtml(curve)}${curveSvg(curve)}</div>
516
+ <details class="data"><summary>Show the data</summary><table><tr><th>Date</th><th class="n">Planned</th><th class="n">Actual</th></tr>
517
+ ${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>
518
+ <script>(${attachCurveHover.toString()})(document.querySelector('.cv-host'));</script>`;
519
+ }
520
+
521
+ /** Where the project stands today, from the curve: planned vs actual and the gap, for the headline line. */
522
+ export function curveToday(curve) {
523
+ const past = curve.points.filter(p => p.actual != null);
524
+ const last = past[past.length - 1];
525
+ if (!last) return null;
526
+ const planned = Math.round(last.planned), actual = Math.round(last.actual), gap = actual - planned; // from the shown numbers, so they add up
527
+ return { planned, actual, gap,
528
+ text: `Today: ${actual}% complete vs ${planned}% planned — ${gap === 0 ? 'on plan' : `${Math.abs(gap)} points ${gap < 0 ? 'behind' : 'ahead'}`}` };
529
+ }
530
+
531
+ /** Legend row + headline above the chart (identity is never colour-only: the lines also carry end labels). */
532
+ export function curveLegendHtml(curve) {
533
+ const today = curveToday(curve);
534
+ 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>`;
535
+ }