← back to NationalPaperHangers
public/js/measure-kit.js
135 lines
/* measure-kit.js — the roll / panel calculator brain for the AR Wall
* Measurement Kit (/measure). Pure arithmetic, zero DOM, zero network — so it
* unit-tests in Node and loads verbatim in the browser. The measurement
* SENSORS live in room-measure.js (window.RoomMeasure); this file only turns
* wall dimensions into "how much wallpaper do I buy."
*
* Trade rules of thumb encoded here (see AR_MEASURE_KIT_SPEC.md §4):
* US standard roll — ~28 ft² usable per single roll, SOLD in double rolls.
* Euro roll — ~21" × 33 ft ≈ 57 ft² usable, sold single.
* Mural / panel — sized by wall WIDTH ÷ panel width, per wall.
* Waste — +15% plain; +20% when the pattern has a repeat
* (drop-match eats more), user-overridable.
*
* Numbers are estimates. Every result carries a "confirm with your installer
* before ordering" caveat — final constants get a sanity pass vs DW product
* data before this ever ships customer-facing.
*/
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory();
else root.MeasureKit = factory();
})(typeof self !== 'undefined' ? self : this, function () {
'use strict';
// Roll formats keyed by id. `usablePerSingle` is ft² of hangable paper after
// trimming; `soldInDoubles` means the store unit is two singles bonded.
var ROLL_FORMATS = {
us_standard: { id: 'us_standard', label: 'US standard roll', usablePerSingle: 28, soldInDoubles: true },
euro: { id: 'euro', label: 'Euro roll (21" × 33 ft)', usablePerSingle: 57, soldInDoubles: false }
};
function round1(n) { return Math.round(n * 10) / 10; }
// Default waste fraction for a given pattern repeat (inches).
// Repeat > 0 → drop-match alignment wastes more paper.
function defaultWaste(repeatIn) {
return (Number(repeatIn) > 0) ? 0.20 : 0.15;
}
// Net area of one wall in ft²: width×height minus any door/window deductions.
// A wall = { width_ft, height_ft, deductions:[{w_ft,h_ft}] } — deductions
// optional. Negative/garbage inputs clamp to 0.
function wallArea(wall) {
var w = Math.max(0, Number(wall.width_ft) || 0);
var h = Math.max(0, Number(wall.height_ft) || 0);
var gross = w * h;
var deduct = 0;
(wall.deductions || []).forEach(function (d) {
var dw = Math.max(0, Number(d.w_ft) || 0);
var dh = Math.max(0, Number(d.h_ft) || 0);
deduct += dw * dh;
});
var net = Math.max(0, gross - deduct);
return { gross: round1(gross), deduct: round1(deduct), net: round1(net) };
}
// Sum net area across N walls (ft²).
function totalArea(walls) {
return round1((walls || []).reduce(function (sum, wll) {
return sum + wallArea(wll).net;
}, 0));
}
// Roll-goods calculation from a total net area.
// opts: { formatId, repeatIn, waste } — waste overrides defaultWaste when a
// finite number ≥ 0 is passed.
function rollsForArea(netArea, opts) {
opts = opts || {};
var fmt = ROLL_FORMATS[opts.formatId] || ROLL_FORMATS.us_standard;
var waste = (typeof opts.waste === 'number' && isFinite(opts.waste) && opts.waste >= 0)
? opts.waste : defaultWaste(opts.repeatIn);
var areaWithWaste = Math.max(0, Number(netArea) || 0) * (1 + waste);
var singles = Math.ceil(areaWithWaste / fmt.usablePerSingle);
if (!isFinite(singles) || singles < 0) singles = 0;
var result = {
format: fmt.label,
formatId: fmt.id,
wastePct: Math.round(waste * 100),
areaWithWaste: round1(areaWithWaste),
singleRolls: singles,
soldInDoubles: fmt.soldInDoubles
};
// Stores sell US paper as double rolls — two bonded singles.
if (fmt.soldInDoubles) result.doubleRolls = Math.ceil(singles / 2);
return result;
}
// Mural / panel goods: panels needed to span each wall's WIDTH. panelWidthIn
// is the printed panel width in inches (default 36"). Returns per-wall +
// total panel counts; height is the caller's responsibility (a mural panel
// must be ordered tall enough to cover the wall — we flag walls taller than
// panelMaxHeightFt if given).
function panelsForWalls(walls, opts) {
opts = opts || {};
var panelWidthFt = Math.max(0.5, (Number(opts.panelWidthIn) || 36) / 12);
var maxH = Number(opts.panelMaxHeightFt) || 0;
var per = (walls || []).map(function (wll, i) {
var w = Math.max(0, Number(wll.width_ft) || 0);
var h = Math.max(0, Number(wll.height_ft) || 0);
var panels = w > 0 ? Math.ceil(w / panelWidthFt) : 0;
return {
wallIndex: i,
panels: panels,
tooTall: (maxH > 0 && h > maxH)
};
});
return {
panelWidthIn: Math.round(panelWidthFt * 12),
perWall: per,
totalPanels: per.reduce(function (s, p) { return s + p.panels; }, 0)
};
}
// Plain-English buy line for a roll result.
function buyLine(rollResult) {
if (!rollResult || !rollResult.singleRolls) return 'Add a wall measurement to size your order.';
if (rollResult.soldInDoubles) {
var d = rollResult.doubleRolls;
return 'Buy ' + d + ' double roll' + (d === 1 ? '' : 's') +
' (' + rollResult.singleRolls + ' single-roll equivalent) of ' + rollResult.format + '.';
}
var s = rollResult.singleRolls;
return 'Buy ' + s + ' roll' + (s === 1 ? '' : 's') + ' of ' + rollResult.format + '.';
}
return {
ROLL_FORMATS: ROLL_FORMATS,
defaultWaste: defaultWaste,
wallArea: wallArea,
totalArea: totalArea,
rollsForArea: rollsForArea,
panelsForWalls: panelsForWalls,
buyLine: buyLine
};
});