Files
willtools-demo/tools/pa_web/static/units.js
T
will 8e6ee31596
ci / test (push) Successful in 6m18s
WillTools 2026-09-09 -- clean snapshot of the development repo for review
History-free snapshot of the private development repository at cbe45c5
(2026-09-09). Code, docs and public catalogue only; no plant or job
knowledge layers, no client or job names (tools/scrub.py clean). The
intro deck is left out of this snapshot because its slide images could
not be checked for job drawings.
2026-09-09 08:32:15 -05:00

247 lines
8.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Feet-inches formatting and parsing -- the browser twin of
// `panellift/src/panellift/units.py`. Same rules, same rounding, same
// accepted input forms; kept as a byte-for-byte port on purpose so a value
// typed on the page and the same value printed by the CPython report never
// disagree. See units.py's own module docstring for the owner's ruling this
// exists to satisfy (2026-09-05: every length on any front-end or results
// page is feet-inches, rounded to the nearest 1/8", never a decimal).
//
// Plain ES2017, no modules -- loaded with a bare <script src="units.js">
// the way the rest of this page's vanilla JS is (see EDIT_JS in
// tools/panelanalysis.py). Exposes window.Units = {fmtFtIn, fmtIn,
// parseLength, parseLengthIn}.
(function () {
'use strict';
function gcd(a, b) {
a = Math.abs(a);
b = Math.abs(b);
while (b) {
var t = b;
b = a % b;
a = t;
}
return a || 1;
}
// A reduced "n/d" fraction string, mirroring Python's `fractions.Fraction`
// reduction (e.g. 4/8 -> "1/2").
function fracString(num, den) {
var g = gcd(num, den);
return (num / g) + '/' + (den / g);
}
// Decimal feet -> "8'-4 1/2\"", rounded to the nearest 1/precision inch.
// null/undefined/NaN -> '-'. Negative values keep the sign in front of the
// whole string ("-0'-6\""), never inside it. A remainder that rounds up to
// a full inch carries into the inch count, and 12 inches carries into the
// foot count.
function fmtFtIn(feet, precision) {
precision = precision || 8;
if (feet === null || feet === undefined || isNaN(feet)) {
return '-';
}
var sign = feet < 0 ? '-' : '';
var value = Math.abs(feet);
var wholeFt = Math.floor(value);
// work in units of 1/precision inch so the carry logic is exact integer
// arithmetic, never a float comparison against 12.0 or 1.0.
var unitsTotal = Math.round((value - wholeFt) * 12.0 * precision);
var inch = Math.floor(unitsTotal / precision);
var rem = unitsTotal - inch * precision;
if (inch >= 12) {
wholeFt += Math.floor(inch / 12);
inch = inch % 12;
}
var out = wholeFt + "'-" + inch;
if (rem) {
out += ' ' + fracString(rem, precision);
}
return sign + out + '"';
}
// Decimal inches -> "6 3/8\"" -- for thicknesses, depths, offsets,
// diameters and anything else already spoken of in inches, never feet.
// null/undefined/NaN -> '-'.
function fmtIn(inches, precision) {
precision = precision || 8;
if (inches === null || inches === undefined || isNaN(inches)) {
return '-';
}
var sign = inches < 0 ? '-' : '';
var value = Math.abs(inches);
var wholeIn = Math.floor(value);
var units = Math.round((value - wholeIn) * precision);
if (units >= precision) {
wholeIn += 1;
units = 0;
}
var out = '' + wholeIn;
if (units) {
out += ' ' + fracString(units, precision);
}
return sign + out + '"';
}
// ------------------------------------------------------------ parsing
// A leading sign, held separately from the feet/inch markers below because
// a hyphen INSIDE the string (the "8'-4" separator, not a minus) must
// never be read as negation -- only a hyphen before the first digit is a
// sign.
var SIGN_RE = /^-\s*/;
// "8", optionally decimal, followed by a foot mark, followed by whatever
// is left (which may be empty, or an inch remainder with its own quote).
var FEET_RE = /^(\d+(?:\.\d+)?)\s*'\s*([\s\S]*)$/;
// A hyphen used as the visual separator between a whole inch and a
// fraction ("4-1/2") rather than as a sign -- only recognised with a digit
// already on its left, so a leading "-" (the "8'-6\"" separator right
// after the foot mark) is stripped as its own case, not caught here.
var HYPHEN_FRAC_RE = /(\d)\s*-\s*(\d+\s*\/\s*\d+)/g;
var MIXED_RE = /^(\d+)\s+(\d+)\s*\/\s*(\d+)$/;
var FRAC_RE = /^(\d+)\s*\/\s*(\d+)$/;
var DEC_RE = /^(\d+(?:\.\d+)?)$/;
function badLength(orig) {
return new Error('not a length: ' + orig);
}
// num/den, or badLength(orig) for a 0 denominator -- a typed "1/0" is bad
// input, not a licence to silently hand back Infinity/NaN the way plain
// JS division would.
function safeDiv(num, den, orig) {
if (den === 0) {
throw badLength(orig);
}
return num / den;
}
function normalizeQuotes(s) {
return s.replace(/’/g, "'").replace(/‘/g, "'")
.replace(/”/g, '"').replace(/“/g, '"');
}
// Parse what follows a foot mark (or the whole string, for a bare inch
// value): an optional leading '-' separator, a whole number and/or a
// fraction, an optional trailing '"'. Returns inches as a number.
function parseInchRemainder(rest, orig, allowEmpty) {
rest = rest.trim();
if (rest.slice(-1) === '"') {
rest = rest.slice(0, -1).trim();
}
if (rest.slice(0, 1) === '-') {
// the "8'-6" separator, not a sign -- the sign (if any) was already
// consumed before the foot mark was even found.
rest = rest.slice(1).trim();
}
if (rest === '') {
if (allowEmpty) {
return 0.0;
}
throw badLength(orig);
}
rest = rest.replace(HYPHEN_FRAC_RE, '$1 $2');
var m = MIXED_RE.exec(rest);
if (m) {
return parseFloat(m[1]) +
safeDiv(parseFloat(m[2]), parseFloat(m[3]), orig);
}
m = FRAC_RE.exec(rest);
if (m) {
return safeDiv(parseFloat(m[1]), parseFloat(m[2]), orig);
}
m = DEC_RE.exec(rest);
if (m) {
return parseFloat(m[1]);
}
throw badLength(orig);
}
// No `'` and no trailing `"` anywhere -- a plain number, or a bare
// fraction / mixed number, read in `defaultUnit`.
function parseBareNumber(s, orig, defaultUnit) {
var value;
var m = DEC_RE.exec(s);
if (m) {
value = parseFloat(m[1]);
} else {
var s2 = s.replace(HYPHEN_FRAC_RE, '$1 $2');
m = MIXED_RE.exec(s2);
if (m) {
value = parseFloat(m[1]) +
safeDiv(parseFloat(m[2]), parseFloat(m[3]), orig);
} else {
m = FRAC_RE.exec(s2);
if (m) {
value = safeDiv(parseFloat(m[1]), parseFloat(m[2]), orig);
} else {
throw badLength(orig);
}
}
}
return defaultUnit === 'ft' ? value * 12.0 : value;
}
// The shared parser. Returns inches, given how a bare unmarked number
// should be read ('ft' or 'in').
function parseCoreIn(text, defaultUnit) {
if (text === null || text === undefined) {
throw badLength(text);
}
if (typeof text === 'number') {
return defaultUnit === 'ft' ? text * 12.0 : text;
}
var orig = String(text);
var s = orig.trim();
if (s === '') {
throw badLength(orig);
}
s = normalizeQuotes(s);
var negative = false;
var signMatch = SIGN_RE.exec(s);
if (signMatch) {
negative = true;
s = s.slice(signMatch[0].length).trim();
}
if (s === '') {
throw badLength(orig);
}
var totalIn;
var feetMatch = FEET_RE.exec(s);
if (feetMatch) {
var feet = parseFloat(feetMatch[1]);
totalIn = feet * 12.0 + parseInchRemainder(feetMatch[2], orig, true);
} else if (s.slice(-1) === '"') {
totalIn = parseInchRemainder(s, orig, false);
} else {
totalIn = parseBareNumber(s, orig, defaultUnit);
}
return negative ? -totalIn : totalIn;
}
// Parse any of the accepted forms and return inches. A bare number with
// no `'` or `"` anywhere is read in `defaultUnit` ('in' by default here).
function parseLengthIn(text, defaultUnit) {
return parseCoreIn(text, defaultUnit || 'in');
}
// Parse any of the accepted forms and return decimal feet. A bare number
// with no `'` or `"` anywhere is read in `defaultUnit` ('ft' by default,
// per the owner's ruling).
function parseLength(text, defaultUnit) {
return parseCoreIn(text, defaultUnit || 'ft') / 12.0;
}
window.Units = {
fmtFtIn: fmtFtIn,
fmtIn: fmtIn,
parseLength: parseLength,
parseLengthIn: parseLengthIn
};
})();