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