25592fa6c2
ci / test (push) Successful in 6m14s
History-free snapshot of the private development repository at 77a82eb. Code, docs and the public catalogue only: no plant or job knowledge layers, no client, job or producer names (tools/scrub.py clean against the widened term list; a second word-level grep clean). The intro deck is left out because its slide images cannot be checked for job drawings.
247 lines
8.0 KiB
JavaScript
247 lines
8.0 KiB
JavaScript
// 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
|
||
};
|
||
})();
|