Image-to-Text
English
custom
ocr
document-ai
information-extraction
key-information-extraction
nameplate
nameplate-ocr
equipment-nameplate
data-center
data-centre
datacenter
mission-critical
mep
electrical
mechanical
equipment-schedule
schedule-verification
asset-register
commissioning
quality-assurance
ups
pdu
switchgear
generator
ats
transformer
busway
battery
crah
crac
chiller
cooling
server-rack
construction
aec
edge-ai
on-device
privacy-preserving
tesseract
browser
Eval Results (legacy)
Upload folder using huggingface_hub
Browse files- README.md +1 -0
- 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 |
+
}
|