Skip to content

Commit 9efd9da

Browse files
Merge pull request #18 from facebookresearch/web-ui-units-rework
Web UI: per-row unit suffixes, Fahrenheit support, dark-mode default
2 parents 77ed783 + e4ef98a commit 9efd9da

7 files changed

Lines changed: 472 additions & 65 deletions

File tree

docs/index.html

Lines changed: 14 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88

99
<!-- SEO -->
1010
<meta name="description" content="Interactive Bayesian Optimization explorer for sustainable concrete: predict 28-day compressive strength, GWP, and cost from mix composition in your browser.">
11-
<meta name="author" content="Sebastian Ament; BOxCrete authors">
11+
<meta name="author" content="Sebastian Ament">
12+
<link rel="author" href="https://sebastianament.github.io">
1213
<link rel="canonical" href="https://facebookresearch.github.io/SustainableConcrete/">
1314

1415
<!-- Open Graph (Facebook, LinkedIn, Slack, iMessage, Discord) -->
@@ -48,13 +49,11 @@
4849
"price": "0",
4950
"priceCurrency": "USD"
5051
},
51-
"author": [
52-
{"@type": "Person", "name": "Bayezid Baten"},
53-
{"@type": "Person", "name": "M. Ayyan Iqbal"},
54-
{"@type": "Person", "name": "Sebastian Ament"},
55-
{"@type": "Person", "name": "Julius Kusuma"},
56-
{"@type": "Person", "name": "Nishant Garg"}
57-
],
52+
"author": {
53+
"@type": "Person",
54+
"name": "Sebastian Ament",
55+
"url": "https://sebastianament.github.io"
56+
},
5857
"citation": [
5958
{
6059
"@type": "ScholarlyArticle",
@@ -76,10 +75,13 @@
7675
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
7776
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
7877
<script>
79-
// Apply theme before render to avoid flash
78+
// Apply theme before render to avoid flash.
79+
// Default is dark for everyone unless they have an explicit stored
80+
// preference (set via the theme-toggle button). We deliberately do NOT
81+
// honor `prefers-color-scheme` so the brand experience is consistent
82+
// across visitors regardless of OS appearance settings.
8083
function getEffectiveTheme() {
81-
return localStorage.getItem('boxcrete-theme') ||
82-
(window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
84+
return localStorage.getItem('boxcrete-theme') || 'dark';
8385
}
8486
function applyTheme() {
8587
const theme = getEffectiveTheme();
@@ -264,7 +266,7 @@ <h3>References</h3>
264266
</main>
265267

266268
<footer class="site-footer">
267-
<span>Explorer designed by <a href="https://sebastianament.github.io" target="_blank" rel="noopener">Sebastian Ament</a></span>
269+
<span>Explorer designed by <a href="https://sebastianament.github.io" target="_blank" rel="author noopener">Sebastian Ament</a></span>
268270
</footer>
269271

270272
<script type="module" src="ui.mjs"></script>
@@ -275,10 +277,6 @@ <h3>References</h3>
275277
localStorage.setItem('boxcrete-theme', current === 'light' ? 'dark' : 'light');
276278
applyTheme();
277279
});
278-
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
279-
localStorage.removeItem('boxcrete-theme');
280-
applyTheme();
281-
});
282280
// Unit toggle (dispatches custom event for ui.mjs to listen to)
283281
document.getElementById('unit-toggle').addEventListener('click', () => {
284282
document.dispatchEvent(new CustomEvent('toggle-units'));

docs/style.css

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,37 @@ h2 {
455455
}
456456
.slider-group label span { font-weight: 600; }
457457

458+
/* Per-row unit suffix in the composition setter (e.g. `kg/m³` / `lb/yd³`,
459+
or `°C` for the temperature row). The wrap container packs the value
460+
input and the unit together at the right side of the label, preserving
461+
the row's two-child `space-between` layout (name on the left, value+unit
462+
on the right). The wrap's `padding-right` matches `.info-row { padding: 0 2px }`
463+
so the unit text and the max-bound text share the same right baseline.
464+
Tight `gap` keeps the value+unit reading as a single typographic unit
465+
(e.g. "353.0 kg/m³"), without the chunky empty space a wider gap creates. */
466+
.slider-value-wrap {
467+
display: inline-flex;
468+
align-items: baseline;
469+
gap: 0.15ch;
470+
padding-right: 2px;
471+
}
472+
/* Override `.slider-group label span { font-weight: 600 }` — the unit
473+
should read as a quiet suffix, not as bold as the value. Same
474+
selector specificity (0,2,1) as the bold rule, declared after it so
475+
the cascade order picks this one up.
476+
No fixed-width slot here: the unit hugs its content so the value
477+
doesn't have a gap on its right. The cost is a ~1 character shift of
478+
the value column on metric ↔ imperial toggle (`kg/m³` 5ch vs `lb/yd³`
479+
6ch) — much less noticeable than a permanent empty gap in the
480+
default (metric) view. */
481+
.slider-group label .slider-unit {
482+
font-weight: 400;
483+
font-size: 0.72rem;
484+
opacity: 0.65;
485+
white-space: nowrap;
486+
text-align: right;
487+
}
488+
458489
/* Click-to-edit composition value (regular sliders only — not Material Source).
459490
Visual treatment mirrors `.toggle-btn`: rectilinear with 4px radius and
460491
accent-colored focus border, matching the existing glass-morph language.
@@ -1134,10 +1165,13 @@ a.ref-link {
11341165
.mobile-sliders-view.fading { opacity: 0; }
11351166
.mobile-scroll-content { display: contents; }
11361167

1137-
/* Site footer — subtle credit with frosted glass pill */
1168+
/* Site footer — subtle credit with frosted glass pill.
1169+
`padding: 0.75rem 0` matches `.layout { gap: 0.75rem }` so the pill sits
1170+
at the same vertical inset from the bottom of the tallest column as
1171+
columns are spaced from each other. */
11381172
.site-footer {
11391173
text-align: center;
1140-
padding: 1.5rem 0 0.75rem;
1174+
padding: 0.75rem 0;
11411175
}
11421176
.site-footer span {
11431177
display: inline-flex;
@@ -1330,6 +1364,13 @@ a.ref-link {
13301364
min-height: 32px;
13311365
padding: 4px 6px;
13321366
}
1367+
/* Mobile info-row uses `padding: 0 7px 0 0` (see below) to align the
1368+
max-bound text with the value input's right edge. Match that here so
1369+
the unit text in the value+unit wrap shares the same right baseline
1370+
as the max bound. */
1371+
.mobile-sliders-view .slider-group .slider-value-wrap {
1372+
padding-right: 7px;
1373+
}
13331374
.mobile-sliders-view .slider-group input[type=range] {
13341375
/* Constrain the slider track to a centered, fixed-but-responsive width
13351376
so it sits in the middle of the panel rather than spanning the full

docs/ui.mjs

Lines changed: 51 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@
55
*/
66

77
import { predictStrengthCurve, predictStrengthMeanOnly, predictGWP, predictCost, initStrengthModel, initWASM } from "./gp.mjs";
8+
import {
9+
UNITS,
10+
compToDisplay,
11+
compFromDisplay,
12+
sliderUnitLabel as sliderUnitLabelFor,
13+
} from "./units.mjs";
814

915
// --- Shared Helpers ---
1016
function easeInOutCubic(t) {
@@ -68,20 +74,8 @@ let _msCurveTransition = null; // {startTime, duration, times, fromMeans, fromSt
6874

6975
// --- Unit System ---
7076
let unitSystem = "metric"; // "metric" or "imperial"
71-
const UNITS = {
72-
metric: {
73-
strength: "MPa", strengthFactor: 1 / 145.04, // psi → MPa
74-
mass: "kg/m³", massFactor: 1,
75-
gwp: "kg CO₂/m³", gwpFactor: 1,
76-
cost: "$/m³", costFactor: 1,
77-
},
78-
imperial: {
79-
strength: "psi", strengthFactor: 1, // already psi
80-
mass: "lb/yd³", massFactor: 1.6856, // kg/m³ → lb/yd³
81-
gwp: "lb CO₂/yd³", gwpFactor: 1.6856,
82-
cost: "$/yd³", costFactor: 1 / 1.30795, // $/m³ → $/yd³
83-
},
84-
};
77+
// `UNITS` is imported from `./units.mjs` (single source of truth, also used
78+
// by the Node-based `test/test_js_units.mjs` parity tests).
8579
function U() { return UNITS[unitSystem]; }
8680

8781
// Animated unit transition
@@ -294,11 +288,22 @@ function buildSliders() {
294288
// negative values are ever entered (no need for `-` key on iOS Safari).
295289
valueInput.inputMode = "decimal";
296290
valueInput.setAttribute("aria-label", `${shortName} value`);
297-
valueInput.value = (currentComposition[i] * sliderDisplayFactor(col)).toFixed(1);
291+
valueInput.value = displayCompValue(col, currentComposition[i]).toFixed(1);
298292
valueInput.dataset.idx = i;
299293
valueInput.dataset.col = col;
300294
attachValueEditHandlers(valueInput, i, col, b);
301-
label.append(nameSpan, valueInput);
295+
296+
// Per-row unit suffix (kg/m³ ↔ lb/yd³ on toggle; °C for Temperature).
297+
// Wrapped in a flex container so the label keeps its two-child
298+
// `space-between` layout (name on the left, value+unit packed on the right).
299+
const valueWrap = document.createElement("span");
300+
valueWrap.className = "slider-value-wrap";
301+
const unitSpan = document.createElement("span");
302+
unitSpan.className = "slider-unit";
303+
unitSpan.id = `unit-${i}`;
304+
unitSpan.textContent = sliderUnitLabel(col);
305+
valueWrap.append(valueInput, unitSpan);
306+
label.append(nameSpan, valueWrap);
302307

303308
const input = document.createElement("input");
304309
input.type = "range";
@@ -378,7 +383,7 @@ function syncSliderDOM(comp, updateValues = true) {
378383
for (const slider of _sliderInputs) {
379384
const idx = parseInt(slider.dataset.idx);
380385
if (updateValues) slider.value = comp[idx];
381-
setValueDisplay(idx, (comp[idx] * sliderDisplayFactor(slider.dataset.col)).toFixed(1));
386+
setValueDisplay(idx, displayCompValue(slider.dataset.col, comp[idx]).toFixed(1));
382387
}
383388
}
384389

@@ -447,7 +452,7 @@ function onSliderChange(e) {
447452
const idx = parseInt(e.target.dataset.idx);
448453
currentComposition[idx] = parseFloat(e.target.value);
449454
displayPreviewComp[idx] = currentComposition[idx];
450-
const displayVal = currentComposition[idx] * sliderDisplayFactor(e.target.dataset.col);
455+
const displayVal = displayCompValue(e.target.dataset.col, currentComposition[idx]);
451456
setValueDisplay(idx, displayVal.toFixed(1));
452457
_sliderActive = true;
453458
if (_sliderIdleTimer) clearTimeout(_sliderIdleTimer);
@@ -458,14 +463,23 @@ function onSliderChange(e) {
458463
checkExtrapolationWarning();
459464
}
460465

461-
// Display factor for a slider column — temperature is never converted
462-
function sliderDisplayFactor(colName) {
463-
if (colName.includes("Temp")) return 1;
464-
return U().massFactor;
466+
// Display value for a composition column under the active unit system.
467+
// Handles both mass (factor) and temperature (factor + offset).
468+
function displayCompValue(colName, internal) {
469+
return compToDisplay(colName, internal, unitSystem);
470+
}
471+
// Inverse of `displayCompValue`: parse a user-typed display value back to
472+
// the model-native (kg/m³ or °C) value before clamping/storage.
473+
function internalCompValue(colName, display) {
474+
return compFromDisplay(colName, display, unitSystem);
475+
}
476+
// Unit suffix label for a slider column (delegates to `units.mjs`).
477+
function sliderUnitLabel(colName) {
478+
return sliderUnitLabelFor(colName, unitSystem);
465479
}
466480
function updateSliderLabels() {
467481
syncSliderDOM(currentComposition, false);
468-
// Update info rows (min/max labels)
482+
// Update info rows (min/max labels) and per-row unit suffixes
469483
const bounds = compositionsData.slider_bounds;
470484
const colNames = compositionsData.column_names;
471485
const infoRows = document.querySelectorAll("#sliders .info-row");
@@ -476,12 +490,16 @@ function updateSliderLabels() {
476490
const b = bounds[col];
477491
if (b.min === b.max) continue;
478492
if (rowIdx < infoRows.length) {
479-
const factor = sliderDisplayFactor(col);
480-
const minDisp = (b.min * factor).toFixed(0);
481-
const maxDisp = (b.max * factor).toFixed(0);
493+
// Use offset-aware converter so temperature bounds render correctly
494+
// in °F (e.g. -20°C → -4°F, 22°C → 72°F) under imperial.
495+
const minDisp = displayCompValue(col, b.min).toFixed(0);
496+
const maxDisp = displayCompValue(col, b.max).toFixed(0);
482497
infoRows[rowIdx].innerHTML = `<span>${minDisp}</span><span>${maxDisp}</span>`;
483498
rowIdx++;
484499
}
500+
// Refresh per-row unit suffix (kg/m³ ↔ lb/yd³, °C ↔ °F)
501+
const unitEl = document.getElementById(`unit-${i}`);
502+
if (unitEl) unitEl.textContent = sliderUnitLabel(col);
485503
}
486504
}
487505

@@ -585,11 +603,12 @@ function attachValueEditHandlers(inputEl, idx, col, b) {
585603
const parsed = parseFloat(raw);
586604
if (!Number.isFinite(parsed)) {
587605
// Non-numeric → revert displayed text
588-
inputEl.value = (currentComposition[idx] * sliderDisplayFactor(col)).toFixed(1);
606+
inputEl.value = displayCompValue(col, currentComposition[idx]).toFixed(1);
589607
return;
590608
}
591-
// Convert displayed value back to internal units, then clamp
592-
const internal = parsed / sliderDisplayFactor(col);
609+
// Convert displayed value back to internal units, then clamp.
610+
// For Temperature this also handles the °F → °C offset.
611+
const internal = internalCompValue(col, parsed);
593612
const clamped = Math.max(b.min, Math.min(b.max, internal));
594613
// Build target from the most recent intended end state to avoid landing
595614
// mid-animation values for sliders that are currently in flight.
@@ -602,7 +621,7 @@ function attachValueEditHandlers(inputEl, idx, col, b) {
602621
// will overwrite, but we want the input to read correctly during the lerp
603622
// since `setValueDisplay` skips focused elements — and this element is
604623
// still focused if commit was triggered by Enter).
605-
inputEl.value = (clamped * sliderDisplayFactor(col)).toFixed(1);
624+
inputEl.value = displayCompValue(col, clamped).toFixed(1);
606625
}
607626
inputEl.addEventListener("focus", () => inputEl.select());
608627
inputEl.addEventListener("keydown", (e) => {
@@ -613,14 +632,14 @@ function attachValueEditHandlers(inputEl, idx, col, b) {
613632
} else if (e.key === "Escape") {
614633
e.preventDefault();
615634
// Revert without committing
616-
inputEl.value = (currentComposition[idx] * sliderDisplayFactor(col)).toFixed(1);
635+
inputEl.value = displayCompValue(col, currentComposition[idx]).toFixed(1);
617636
inputEl.blur();
618637
}
619638
});
620639
inputEl.addEventListener("blur", () => {
621640
// Blur commits the edit (same as Enter), but only if the value was changed.
622641
// If the value matches the current displayed state, do nothing.
623-
const expected = (currentComposition[idx] * sliderDisplayFactor(col)).toFixed(1);
642+
const expected = displayCompValue(col, currentComposition[idx]).toFixed(1);
624643
if (inputEl.value.trim() !== expected) commit();
625644
});
626645
}

docs/units.mjs

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// Pure unit-system definitions and converters for the web UI.
2+
//
3+
// All native (stored) values are in metric — see `boxcrete/units.py` for the
4+
// canonical authority over data units in this repo:
5+
// compositions: kg/m³, strength: psi, temperature: °C,
6+
// GWP: kg CO₂/m³, cost: $/m³, slump: inches.
7+
//
8+
// Imperial display layers in the UI apply the conversions below at render
9+
// time. Composition/GWP/cost are simple linear factors; temperature has an
10+
// offset (F = C × 9/5 + 32) and needs explicit converter functions.
11+
//
12+
// This module is pure (no DOM, no global state) so it can be imported by
13+
// `ui.mjs` *and* by Node-only tests (`test/test_js_units.mjs`).
14+
15+
export const UNITS = {
16+
metric: {
17+
strength: "MPa",
18+
strengthFactor: 1 / 145.04, // psi → MPa
19+
mass: "kg/m³",
20+
massFactor: 1,
21+
gwp: "kg CO₂/m³",
22+
gwpFactor: 1,
23+
cost: "$/m³",
24+
costFactor: 1,
25+
temp: "°C",
26+
},
27+
imperial: {
28+
strength: "psi",
29+
strengthFactor: 1, // already psi
30+
mass: "lb/yd³",
31+
massFactor: 1.6856, // kg/m³ → lb/yd³
32+
gwp: "lb CO₂/yd³",
33+
gwpFactor: 1.6856,
34+
cost: "$/yd³",
35+
costFactor: 1 / 1.30795, // $/m³ → $/yd³ (1 yd³ = 0.7646 m³)
36+
temp: "°F",
37+
},
38+
};
39+
40+
// Whether a column name represents a temperature column (matches the
41+
// dataset's "Temp (C)" naming convention).
42+
export function isTempColumn(colName) {
43+
return colName.includes("Temp");
44+
}
45+
46+
// Convert a celsius value to the active unit system's display value.
47+
// `F = C × 9/5 + 32` for imperial; identity for metric.
48+
// NOTE: temperature conversion is *not* a single multiplicative factor
49+
// because of the `+32` offset — animation/interpolation code that mixes
50+
// two unit systems linearly cannot use this in the same way as mass or
51+
// strength. Sliders snap on unit toggle, so this is fine for them.
52+
export function celsiusToDisplay(celsius, unitSystem) {
53+
return unitSystem === "imperial" ? celsius * 9 / 5 + 32 : celsius;
54+
}
55+
56+
// Inverse of `celsiusToDisplay`: parse a displayed value back to celsius
57+
// for storage in the model-native composition vector.
58+
export function displayToCelsius(display, unitSystem) {
59+
return unitSystem === "imperial" ? (display - 32) * 5 / 9 : display;
60+
}
61+
62+
// Convert a stored composition value (the model-native units: kg/m³ for
63+
// masses, °C for temperature) to the value that should be shown to the
64+
// user given the active unit system.
65+
//
66+
// Caller is responsible for excluding non-numeric columns like
67+
// "Material Source" before invoking this.
68+
export function compToDisplay(colName, internal, unitSystem) {
69+
if (isTempColumn(colName)) return celsiusToDisplay(internal, unitSystem);
70+
return internal * UNITS[unitSystem].massFactor;
71+
}
72+
73+
// Inverse of `compToDisplay`. Round-trip identity:
74+
// compFromDisplay(col, compToDisplay(col, x, u), u) === x (mod fp).
75+
export function compFromDisplay(colName, display, unitSystem) {
76+
if (isTempColumn(colName)) return displayToCelsius(display, unitSystem);
77+
return display / UNITS[unitSystem].massFactor;
78+
}
79+
80+
// Per-row unit suffix label: "kg/m³"/"lb/yd³" for mass, "°C"/"°F" for
81+
// temperature. Used by the composition setter panel and updated whenever
82+
// the unit system toggles.
83+
export function sliderUnitLabel(colName, unitSystem) {
84+
if (isTempColumn(colName)) return UNITS[unitSystem].temp;
85+
return UNITS[unitSystem].mass;
86+
}

0 commit comments

Comments
 (0)