/* =============================================================================
Alpha Decking — Composite Cladding Calculator
-----------------------------------------------------------------------------
Sister of deckingCalculator-v32.txt. Same architecture, same page contract
conventions, same quote/share/rebuild machinery; only the product set, the
calculation and the drawing differ. Read that file's header for the "why"
behind the settle-loop, the quote state machine and the Webflow quirks —
none of it is repeated here.
HOW IT FITS TOGETHER
CONFIG every price, product ID, dimension and business rule.
readState reads the controls (walls, sides, openings, direction, colour)
into a plain object. All control lookups live in SEL.
calculate pure function: state in, quantities and prices out. No DOM.
render* product lines, totals, info lines, the elevation SVG per wall.
PAGE CONTRACT
[data-calc-scope] wraps anything that should trigger recalculation
[data-calc-wall="1".."6"] a wall block. Inside it, by CLASS (bare inputs
in small HTML Embeds, ids stable per wall n):
#w{n}n .wall-name optional name (text)
#w{n}w .wall-width width in metres (number)
#w{n}h .wall-height height in metres (number)
input[name="w{n}L"] left side value corner|end|none (radio)
input[name="w{n}R"] right side value corner|end|none (radio)
input[name="w{n}T"] top edge value end|none (radio)
input[name="w{n}B"] bottom edge value end|none (radio)
[data-calc-opening] up to 3 opening rows, each with
#w{n}o{k}w .op-width #w{n}o{k}h .op-height
[data-calc-add-opening] reveals the next hidden opening row
[data-calc-remove-opening] inside a row: clears + hides it
[data-calc-remove-wall] clears + hides this wall block (walls 2–6)
[data-calc-add-wall] reveals the next hidden wall block
Wall blocks 2–6 and opening rows 2–3 start hidden: give them the
attribute data-calc-hidden (the script toggles display, never classes).
input[name="boardDirection"] value horizontal|vertical (radio)
#ob1Checkbox add OB1 adhesive (checkbox)
[data-swatch][data-name][data-hex] colour swatches; data-name must
match the product Colour option text exactly
[data-calc-line] a product block; role/range read from bound
[data-calc-meta="role"/"range"] text inside it
(Webflow can't bind a CMS field to an attribute).
ONE board block; the script sets its Length
dropdown to the job's panel length (v3).
[data-line-price] price element within a product block
[data-calc-placeholder] shown until at least one wall has a size
[data-calc-subtotal] / #subtotal
[data-calc-info="screws"] text: approx 35mm screw count (not sold)
[data-calc-info="area"] text: area clad
[data-calc-info-row] wrapper hidden while there is no wall
[data-cladding-plans] empty div the elevations are injected into
(any number of them)
[data-calc-for="id"] makes ANY element act as the label for input #id
[data-share-url] optional: the share link is written here
[data-quote-email] [data-quote-consent] [data-quote-send] [data-quote-status]
Roles (calc-role in the CMS): board cornerTrim endTrim ob1, and
joistStandard (the shared Composite Joist — read as "batten" here).
Range (calc-range in the CMS): juniper — omitted on joist and ob1.
Product IDs and prices verified against live Webflow SKUs, 8 Sep 2026.
No jQuery. Money is handled in pence as integers throughout.
v1 (9 Sep 2026): first build, from mock-up v3. Quote webhook is EMPTY until
the cladding Make.com scenario exists — the button reports "not set up yet".
v2 (9 Sep 2026): the joist and OB1 products are shared with the decking
calculator, whose page reads their existing CMS calc-role values
(joistStandard / ob1). Rather than change those and break decking, this
script accepts joistStandard as an alias for its batten role. Nothing
else changed.
v3 (9 Sep 2026): ONE panel length per job. Webflow's commerce script keeps
every add-to-cart form for the same product in lockstep, so two board
blocks (2.25m and 3.66m) can never hold different Length selections. The
page now has a single board block; the calculator picks the length for
the whole job — 2.25m only when every wall fits inside a 2.25m panel
uncut, otherwise 3.66m throughout — and sets the block's Length dropdown
to match. Per-metre prices are near-identical (£7.91 vs £7.92) so the
customer pays the same per metre either way. Roles are now: board,
cornerTrim, endTrim, ob1, joistStandard→batten. data-length on the block
is no longer needed and is ignored.
============================================================================= */
(function () {
'use strict';
/* ===========================================================================
CONFIG — everything adjustable lives here
=========================================================================== */
const CONFIG = {
/* --- Geometry (installation guide + CMS spec) ------------------------- */
coverWidth: 0.200,
// CMS spec "200mm coverage" per 219mm tongue-and-groove panel; guide says
// "200 mm per panel". Rows = ceil(across / 0.2).
battenSpacing: 0.500,
// Guide: "500 mm maximum centres". Battens run at 90° to the panels.
minPieceLength: 0.50,
// A cut piece must reach the next batten line at 500mm centres. Used only
// to choose stagger offsets in the layout; it never changes panel counts.
endGap: 0.005,
// Guide: 5 mm between a panel end and a trim / corner. Taken off the run
// once per trimmed end so a wall exactly one panel long is not called
// "uncut" when it can't be.
/* --- Waste (installation guide, verbatim) ----------------------------- */
wasteCut: 1.10, // "Add 10% for cuts"
wasteCutOpenings: 1.15, // "15% if there are several windows and doors"
// Applied to STAGGERED (cut) runs only. Where every row is one uncut panel
// the offcut loss is exact and already counted.
maxWalls: 6,
maxOpenings: 3,
/* --- Products ----------------------------------------------------------- */
products: {
// Slatted Composite Cladding | Juniper Range — one product, two lengths.
board225: { id: '6a82dda25e055ed0d0e976bb', length: 2.25, price: 1780, optionText: '2.25m' },
board366: { id: '6a82dda25e055ed0d0e976bb', length: 3.66, price: 2900, optionText: '3.66m' },
// Composite Joist 3.66m — the batten.
batten: { id: '62daf105ecbbe48ef2f9ec34', length: 3.66, price: 1370 },
// Trims are 2.25m only, every colour £10.00.
cornerTrim: { id: '6a832845e0ba0934acc6d16b', length: 2.25, price: 1000 },
endTrim: { id: '6a97df7bd2cbd18a4aa44f1c', length: 2.25, price: 1000 },
// OB1 300ml — optional, one tube per three trims (same rule as decking).
ob1: { id: '6169ad68ad94d4602117efb1', price: 800, perTrims: 3 }
},
/* --- Colours ------------------------------------------------------------ */
// Keys must match the product Colour option text EXACTLY (CMS says
// "Black", not "Charcoal"). hex colours the elevation.
colours: {
'Black': { hex: '#2b2b2b' },
'Birch & Black': { hex: '#8a7a62' },
'Silver Grey & Black': { hex: '#8d9296' },
'Stone Grey & Black': { hex: '#6f6c66' }
},
// Recognises a Colour dropdown in a product block (a select whose options
// include at least two of these).
colourNames: ['Black', 'Birch & Black', 'Silver Grey & Black', 'Stone Grey & Black'],
/* --- Email me this quote ------------------------------------------------ */
quote: {
webhookUrl: '', // TODO: cladding Make.com scenario (clone of 7169838)
planImageWidth: 1600,
cooldownMs: 8000,
rateLimitMs: 10 * 60 * 1000,
rateLimitBufferMs: 15 * 1000,
storageKey: 'adCladQuoteSends',
labels: { sending: 'Sending…', sent: '✓ Sent — check your inbox' }
},
/* --- Elevation drawing --------------------------------------------------- */
plan: {
bondSteps: 3, // third-bond stagger on cut runs
maxDetailRows: 420,
fallbackColour: '#2b2b2b',
windowSill: 0.9, // metres — where an auto-placed window sits
doorMinHeight: 1.85, // an opening at least this tall (and ≤ doorMaxWidth wide) draws as a door
doorMaxWidth: 1.3
}
};
/* ===========================================================================
DOM CONTRACT — selectors
=========================================================================== */
const SEL = {
scope: '[data-calc-scope]',
wall: '[data-calc-wall]',
wallName: '.wall-name',
wallWidth: '.wall-width',
wallHeight: '.wall-height',
opening: '[data-calc-opening]',
opWidth: '.op-width',
opHeight: '.op-height',
addWall: '[data-calc-add-wall]',
removeWall: '[data-calc-remove-wall]',
addOpening: '[data-calc-add-opening]',
removeOpening:'[data-calc-remove-opening]',
direction: 'input[name="boardDirection"]',
wantOb1: '#ob1Checkbox',
line: '[data-calc-line]',
qty: 'input[name="commerce-add-to-cart-quantity-input"]',
linePrice: '[data-line-price]',
placeholder: '[data-calc-placeholder]',
subtotal: '#subtotal, [data-calc-subtotal]',
infoScrews: '[data-calc-info="screws"]',
infoArea: '[data-calc-info="area"]',
infoRow: '[data-calc-info-row]',
plans: '[data-cladding-plans]',
swatch: '[data-swatch]',
shareUrl: '[data-share-url]',
quoteEmail: '[data-quote-email]',
quoteConsent: '[data-quote-consent]',
quoteSend: '[data-quote-send]',
quoteStatus: '[data-quote-status]'
};
const $ = (s, r) => {
try { return (r || document).querySelector(s); } catch (e) { return null; }
};
const $$ = (s, r) => {
try { return Array.from((r || document).querySelectorAll(s)); }
catch (e) { return []; }
};
/* ===========================================================================
HELPERS (as decking v32)
=========================================================================== */
const money = p => '£' + (p / 100).toFixed(2);
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
const norm = t => (t || '').trim().toLowerCase();
function setVisible(el, show) {
if (!el) return;
el.hidden = !show;
if (!show) { el.style.display = 'none'; return; }
el.style.display = el.dataset.calcDisplay || '';
if (getComputedStyle(el).display === 'none') {
el.style.display = el.dataset.calcDisplay || 'block';
}
}
const isVisible = el => !!el && el.style.display !== 'none' && !el.hidden;
function numOf(el) {
if (!el) return 0;
const v = parseFloat(el.value);
return Number.isFinite(v) && v > 0 ? v : 0;
}
const isChecked = sel => { const el = $(sel); return !!(el && el.checked); };
function radio(name, fallback, root) {
const el = $(name + ':checked', root);
return el ? el.value : fallback;
}
function radioIn(root, name, fallback) {
return radio('input[name="' + name + '"]', fallback, root);
}
/* ===========================================================================
WALL BLOCKS — fixed blocks in the Designer, revealed on demand
=========================================================================== */
const wallBlocks = () => $$(SEL.wall).sort((a, b) => (+a.dataset.calcWall) - (+b.dataset.calcWall));
const wallActive = el => !el.hasAttribute('data-calc-hidden');
function clearInputs(root) {
$$('input', root).forEach(i => {
if (i.type === 'radio' || i.type === 'checkbox') return;
i.value = '';
});
}
function showBlock(el) { el.removeAttribute('data-calc-hidden'); setVisible(el, true); }
function hideBlock(el) { el.setAttribute('data-calc-hidden', ''); setVisible(el, false); clearInputs(el); }
function addWall() {
const next = wallBlocks().find(el => !wallActive(el));
if (next) showBlock(next);
refreshAddButtons();
}
function addOpening(wallEl) {
const next = $$(SEL.opening, wallEl).find(el => el.hasAttribute('data-calc-hidden'));
if (next) showBlock(next);
refreshAddButtons();
}
function refreshAddButtons() {
const moreWalls = wallBlocks().some(el => !wallActive(el));
$$(SEL.addWall).forEach(b => setVisible(b, moreWalls));
wallBlocks().forEach(wallEl => {
const more = $$(SEL.opening, wallEl).some(el => el.hasAttribute('data-calc-hidden'));
$$(SEL.addOpening, wallEl).forEach(b => setVisible(b, more));
});
}
function initBlocks() {
// Anything marked hidden in the Designer starts hidden; wall 1 is always shown.
wallBlocks().forEach((el, i) => {
if (i === 0) showBlock(el);
else if (el.hasAttribute('data-calc-hidden')) setVisible(el, false);
$$(SEL.opening, el).forEach(o => { if (o.hasAttribute('data-calc-hidden')) setVisible(o, false); });
});
refreshAddButtons();
}
/* ===========================================================================
STATE
=========================================================================== */
function readState() {
const walls = wallBlocks().filter(wallActive).map(el => {
const n = +el.dataset.calcWall;
return {
n,
name: (($(SEL.wallName, el) || {}).value || '').trim().slice(0, 40),
width: numOf($(SEL.wallWidth, el)),
height: numOf($(SEL.wallHeight, el)),
left: radioIn(el, 'w' + n + 'L', 'end'),
right: radioIn(el, 'w' + n + 'R', 'end'),
top: radioIn(el, 'w' + n + 'T', 'none'),
bottom: radioIn(el, 'w' + n + 'B', 'none'),
openings: $$(SEL.opening, el)
.filter(o => !o.hasAttribute('data-calc-hidden'))
.map(o => ({ w: numOf($(SEL.opWidth, o)), h: numOf($(SEL.opHeight, o)) }))
.filter(o => o.w > 0 && o.h > 0)
};
});
return {
direction: radio(SEL.direction, 'horizontal'),
wantOb1: isChecked(SEL.wantOb1),
colour: selectedColourName,
walls
};
}
/* ===========================================================================
LAYOUT HELPERS (decking v32, minPiece 0.50)
=========================================================================== */
function piecesAt(offset, run, L) {
const out = []; let s = -offset;
while (s < run - 1e-4) {
const a = Math.max(0, s), b = Math.min(run, s + L);
if (b - a > 1e-4) out.push([a, b]);
s += L;
}
return out;
}
const offsetCache = {};
function layoutOffsets(run, L, steps, minPiece) {
const key = [run, L, steps, minPiece].join('|');
if (offsetCache[key]) return offsetCache[key];
const valid = [];
for (let o = 0; o < L - 1e-6; o += 0.01) {
const shortest = piecesAt(o, run, L).reduce((m, p) => Math.min(m, p[1] - p[0]), Infinity);
if (shortest >= minPiece - 1e-6) valid.push(+o.toFixed(3));
}
let chosen = [];
if (!valid.length) { for (let i = 0; i < steps; i++) chosen.push(i * (L / steps)); }
else for (let i = 0; i < steps; i++) {
const ideal = i * (L / steps); let best = null, bestD = Infinity;
for (const o of valid) {
if (chosen.some(c => Math.abs(c - o) < minPiece || Math.abs(Math.abs(c - o) - L) < minPiece)) continue;
const d = Math.abs(o - ideal); if (d < bestD) { bestD = d; best = o; }
}
chosen.push(best === null ? valid[i % valid.length] : best);
}
offsetCache[key] = chosen; return chosen;
}
function rowPieces(r, run, L, mode) {
if (mode === 'uncut') return [[0, run]];
const offs = layoutOffsets(run, L, CONFIG.plan.bondSteps, CONFIG.minPieceLength);
return piecesAt(offs[r % offs.length], run, L);
}
/* ===========================================================================
CALCULATE — pure. No DOM access in here.
=========================================================================== */
function calcWall(w, direction, forceLen) {
const P = CONFIG.products;
const horizontal = direction === 'horizontal';
const run = horizontal ? w.width : w.height; // panels run along this
const across = horizontal ? w.height : w.width; // rows stack along this
const out = {
n: w.n, name: w.name || ('Wall ' + w.n), run, across, horizontal,
rows: 0, q225: 0, q366: 0, mode: 'none', L: 0, joints: 0, jointLines: 0,
intermediate: 0, battenPieces: [], trims: { corner: 0, end: 0 }, screws: 0,
openings: w.openings, sqmGross: 0, sqmNet: 0, waste: 1, usable: 0,
sides: { left: w.left, right: w.right, top: w.top, bottom: w.bottom }
};
if (run <= 0 || across <= 0) return out;
// Ends of the RUN: horizontal → left/right, vertical → top/bottom.
const endA = horizontal ? w.left : w.top, endB = horizontal ? w.right : w.bottom;
const gaps = (endA !== 'none' ? 1 : 0) + (endB !== 'none' ? 1 : 0);
const usable = run - gaps * CONFIG.endGap;
out.usable = usable;
out.rows = Math.ceil(across / CONFIG.coverWidth);
const openArea = w.openings.reduce((a, o) => a + o.w * o.h, 0);
out.sqmGross = run * across;
out.sqmNet = Math.max(0, out.sqmGross - openArea);
/* --- Panels ---------------------------------------------------------
A run that fits inside one panel gets uncut panels: the shortest
length that covers it, and several rows per panel where the run is
short enough. Longer runs use 3.66m panels cut and staggered, with the
guide's waste figure. Opening area only reduces cut runs — with uncut
rows every row still needs a panel either side of the opening. */
if (!forceLen && usable <= P.board225.length) {
out.mode = 'uncut'; out.L = P.board225.length;
out.q225 = Math.ceil(out.rows / Math.max(1, Math.floor(P.board225.length / usable)));
} else if (usable <= P.board366.length) {
out.mode = 'uncut'; out.L = P.board366.length;
out.q366 = Math.ceil(out.rows / Math.max(1, Math.floor(P.board366.length / usable)));
} else {
out.mode = 'cut'; out.L = P.board366.length;
out.waste = w.openings.length ? CONFIG.wasteCutOpenings : CONFIG.wasteCut;
const linear = out.rows * usable - openArea / CONFIG.coverWidth;
out.q366 = Math.ceil(linear / P.board366.length * out.waste);
// Distinct joint positions → twin batten lines (guide: "a twin at every butt joint").
const pos = new Set();
for (let r = 0; r < out.rows; r++) rowPieces(r, usable, out.L, 'cut').forEach(p => {
if (p[1] < usable - 1e-4) { out.joints++; pos.add(Math.round(p[1] * 100)); }
});
out.jointLines = pos.size;
}
/* --- Battens (guide: "run ÷ 0.5 m, plus one, plus the perimeter, plus a
twin at every butt joint") ------------------------------------------- */
out.intermediate = Math.ceil(run / CONFIG.battenSpacing) + 1;
for (let i = 0; i < out.intermediate + out.jointLines; i++) out.battenPieces.push(across);
out.battenPieces.push(run, run); // the two perimeter battens
out.screws = out.rows * out.intermediate; // one 35mm screw per panel per batten crossed
/* --- Trims --------------------------------------------------------------- */
const H = w.height, W = w.width;
out.trims.corner = (w.left === 'corner' ? H : 0) + (w.right === 'corner' ? H : 0);
out.trims.end = (w.left === 'end' ? H : 0) + (w.right === 'end' ? H : 0) +
(w.top === 'end' ? W : 0) + (w.bottom === 'end' ? W : 0) +
w.openings.reduce((a, o) => a + 2 * (o.w + o.h), 0);
return out;
}
/** Sticks of length L needed to cut the given pieces, pooled by identical length. */
function sticksFor(pieces, L) {
const groups = {};
pieces.forEach(p => { const k = p.toFixed(3); groups[k] = (groups[k] || 0) + 1; });
let sticks = 0;
Object.keys(groups).forEach(k => {
const len = +k, n = groups[k];
sticks += len <= L ? Math.ceil(n / Math.floor(L / len)) : n * Math.ceil(len / L);
});
return sticks;
}
function calculate(s) {
const P = CONFIG.products;
/* v3: one panel length for the whole job. Pass 1 lets each wall pick its
own best length; if that yields a mix, pass 2 forces 3.66m everywhere. */
let walls = s.walls.map(w => calcWall(w, s.direction, null));
let live = walls.filter(w => w.run > 0);
const sum = f => live.reduce((a, w) => a + f(w), 0);
let panelLength = 2.25;
if (live.some(w => w.q366 > 0)) {
panelLength = 3.66;
if (live.some(w => w.q225 > 0)) {
walls = s.walls.map(w => calcWall(w, s.direction, 3.66));
live = walls.filter(w => w.run > 0);
}
}
const boardCfg = panelLength === 2.25 ? P.board225 : P.board366;
const boards = sum(w => w.q225 + w.q366);
const battens = sticksFor([].concat(...live.map(w => w.battenPieces)), P.batten.length);
const corner = Math.ceil(sum(w => w.trims.corner) / P.cornerTrim.length);
const end = Math.ceil(sum(w => w.trims.end) / P.endTrim.length);
const ob1 = s.wantOb1 && corner + end > 0 ? Math.ceil((corner + end) / P.ob1.perTrims) : 0;
const has = live.length > 0;
const lines = [];
const add = (role, cfg, qty, visible) => lines.push({
role, id: cfg.id, qty: visible ? Math.max(0, qty) : 0, price: cfg.price,
visible: !!visible && qty > 0
});
add('board', boardCfg, boards, has);
add('batten', P.batten, battens, has);
add('cornerTrim', P.cornerTrim, corner, has);
add('endTrim', P.endTrim, end, has);
add('ob1', P.ob1, ob1, has && s.wantOb1);
return {
lines, walls, has, panelLength, boards,
screws: sum(w => w.screws),
sqmGross: sum(w => w.sqmGross), sqmNet: sum(w => w.sqmNet),
direction: s.direction, colour: s.colour
};
}
/* ===========================================================================
RENDER
=========================================================================== */
let selectedColourName = null; // set from swatches / share link / product panel
let lastResult = null, lastTotals = null;
function metaOf(el, which, attrValue) {
if (attrValue) return attrValue.trim();
const node = el.querySelector('[data-calc-meta="' + which + '"]');
return node ? node.textContent.trim() : '';
}
/** The effective role of a product block: the board product's two blocks
are told apart by data-length="2.25" / "3.66" on the block. */
const ROLE_ALIASES = { joistStandard: 'batten' }; // v2: shared decking product
function lineRole(el) {
const r = metaOf(el, 'role', el.dataset.role);
return ROLE_ALIASES[r] || r;
}
function findLines(role) {
return $$(SEL.line).filter(el => lineRole(el) === role);
}
function render(result) {
$$(SEL.placeholder).forEach(el => setVisible(el, !result.has));
let subtotal = 0;
const totals = {};
result.lines.forEach(line => {
const total = line.qty * line.price;
totals[line.role] = total;
subtotal += total;
findLines(line.role).forEach(el => {
setVisible(el, line.visible);
const qtyInput = $(SEL.qty, el);
if (qtyInput) {
qtyInput.value = line.qty;
qtyInput.dispatchEvent(new Event('change', { bubbles: true }));
}
const priceEl = $(SEL.linePrice, el);
if (priceEl) priceEl.textContent = money(total);
});
});
// Any product block with a role the calculator doesn't produce stays hidden.
const known = result.lines.map(l => l.role);
$$(SEL.line).forEach(el => { if (!known.includes(lineRole(el))) setVisible(el, false); });
$$(SEL.subtotal).forEach(el => { el.textContent = money(subtotal); });
$$(SEL.infoRow).forEach(el => setVisible(el, result.has));
$$(SEL.infoScrews).forEach(el => { el.textContent = 'approx. ' + result.screws; });
$$(SEL.infoArea).forEach(el => {
el.textContent = result.sqmNet.toFixed(1) + ' m²' +
(result.sqmNet < result.sqmGross - 1e-6 ? ' (' + result.sqmGross.toFixed(1) + ' m² before openings)' : '');
});
lastResult = result;
lastTotals = totals;
renderSwatches();
renderPlans(result);
$$(SEL.shareUrl).forEach(el => { el.textContent = buildShareUrl(readState()); });
}
function renderSwatches() {
$$(SEL.swatch).forEach(el => el.classList.toggle('is-selected', norm(el.dataset.name) === norm(selectedColourName)));
}
/* ===========================================================================
ELEVATION — the wall as it will look from outside: slatted panels,
openings as windows/doors, trims on the edges, eaves and ground. Battens
are behind the cladding so they are not drawn. Reads geometry from the
calculation result only. Draws into EVERY [data-cladding-plans] host.
=========================================================================== */
function ensurePlanStyles() {
if (document.getElementById('calc-plan-css')) return;
const st = document.createElement('style');
st.id = 'calc-plan-css';
st.textContent =
'.calc-plan-wrap{position:relative;width:100%;margin-bottom:18px;}' +
'.calc-plan-wrap svg{display:block;width:100%;height:auto;}' +
'.calc-plan-wrap .pc:hover .body{fill:rgba(15,110,99,.35);stroke:#0f6e63;stroke-width:1.5;}' +
'.calc-plan-tip{position:absolute;pointer-events:none;opacity:0;' +
'transform:translate(-50%,-145%);background:#0f6e63;color:#f6f4ef;' +
'padding:6px 10px;border-radius:3px;font-size:11.5px;white-space:nowrap;' +
'transition:opacity .1s;z-index:5;}' +
'.calc-plan-tip small{display:block;opacity:.75;font-size:10px;}' +
'.calc-plan-tip.on{opacity:1;}' +
'.calc-plan-title{display:flex;justify-content:space-between;font-size:13px;color:#5a6b74;margin:0 0 4px;}' +
'[data-calc-for]{cursor:pointer;}';
document.head.appendChild(st);
}
function lum(hex) {
const n = parseInt(hex.slice(1), 16);
return (0.299 * ((n >> 16) & 255) + 0.587 * ((n >> 8) & 255) + 0.114 * (n & 255)) / 255;
}
function shade(hex, amt) {
const n = parseInt(hex.slice(1), 16);
const c = i => Math.max(0, Math.min(255, ((n >> i) & 255) + amt));
return '#' + ((1 << 24) + (c(16) << 16) + (c(8) << 8) + c(0)).toString(16).slice(1);
}
function dimH(x1, x2, y, label) {
return '