Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 14 additions & 16 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@

<!-- SEO -->
<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.">
<meta name="author" content="Sebastian Ament; BOxCrete authors">
<meta name="author" content="Sebastian Ament">
<link rel="author" href="https://sebastianament.github.io">
<link rel="canonical" href="https://facebookresearch.github.io/SustainableConcrete/">

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

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

<script type="module" src="ui.mjs"></script>
Expand All @@ -275,10 +277,6 @@ <h3>References</h3>
localStorage.setItem('boxcrete-theme', current === 'light' ? 'dark' : 'light');
applyTheme();
});
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
localStorage.removeItem('boxcrete-theme');
applyTheme();
});
// Unit toggle (dispatches custom event for ui.mjs to listen to)
document.getElementById('unit-toggle').addEventListener('click', () => {
document.dispatchEvent(new CustomEvent('toggle-units'));
Expand Down
45 changes: 43 additions & 2 deletions docs/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,37 @@ h2 {
}
.slider-group label span { font-weight: 600; }

/* Per-row unit suffix in the composition setter (e.g. `kg/m³` / `lb/yd³`,
or `°C` for the temperature row). The wrap container packs the value
input and the unit together at the right side of the label, preserving
the row's two-child `space-between` layout (name on the left, value+unit
on the right). The wrap's `padding-right` matches `.info-row { padding: 0 2px }`
so the unit text and the max-bound text share the same right baseline.
Tight `gap` keeps the value+unit reading as a single typographic unit
(e.g. "353.0 kg/m³"), without the chunky empty space a wider gap creates. */
.slider-value-wrap {
display: inline-flex;
align-items: baseline;
gap: 0.15ch;
padding-right: 2px;
}
/* Override `.slider-group label span { font-weight: 600 }` — the unit
should read as a quiet suffix, not as bold as the value. Same
selector specificity (0,2,1) as the bold rule, declared after it so
the cascade order picks this one up.
No fixed-width slot here: the unit hugs its content so the value
doesn't have a gap on its right. The cost is a ~1 character shift of
the value column on metric ↔ imperial toggle (`kg/m³` 5ch vs `lb/yd³`
6ch) — much less noticeable than a permanent empty gap in the
default (metric) view. */
.slider-group label .slider-unit {
font-weight: 400;
font-size: 0.72rem;
opacity: 0.65;
white-space: nowrap;
text-align: right;
}

/* Click-to-edit composition value (regular sliders only — not Material Source).
Visual treatment mirrors `.toggle-btn`: rectilinear with 4px radius and
accent-colored focus border, matching the existing glass-morph language.
Expand Down Expand Up @@ -1134,10 +1165,13 @@ a.ref-link {
.mobile-sliders-view.fading { opacity: 0; }
.mobile-scroll-content { display: contents; }

/* Site footer — subtle credit with frosted glass pill */
/* Site footer — subtle credit with frosted glass pill.
`padding: 0.75rem 0` matches `.layout { gap: 0.75rem }` so the pill sits
at the same vertical inset from the bottom of the tallest column as
columns are spaced from each other. */
.site-footer {
text-align: center;
padding: 1.5rem 0 0.75rem;
padding: 0.75rem 0;
}
.site-footer span {
display: inline-flex;
Expand Down Expand Up @@ -1330,6 +1364,13 @@ a.ref-link {
min-height: 32px;
padding: 4px 6px;
}
/* Mobile info-row uses `padding: 0 7px 0 0` (see below) to align the
max-bound text with the value input's right edge. Match that here so
the unit text in the value+unit wrap shares the same right baseline
as the max bound. */
.mobile-sliders-view .slider-group .slider-value-wrap {
padding-right: 7px;
}
.mobile-sliders-view .slider-group input[type=range] {
/* Constrain the slider track to a centered, fixed-but-responsive width
so it sits in the middle of the panel rather than spanning the full
Expand Down
83 changes: 51 additions & 32 deletions docs/ui.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@
*/

import { predictStrengthCurve, predictStrengthMeanOnly, predictGWP, predictCost, initStrengthModel, initWASM } from "./gp.mjs";
import {
UNITS,
compToDisplay,
compFromDisplay,
sliderUnitLabel as sliderUnitLabelFor,
} from "./units.mjs";

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

// --- Unit System ---
let unitSystem = "metric"; // "metric" or "imperial"
const UNITS = {
metric: {
strength: "MPa", strengthFactor: 1 / 145.04, // psi → MPa
mass: "kg/m³", massFactor: 1,
gwp: "kg CO₂/m³", gwpFactor: 1,
cost: "$/m³", costFactor: 1,
},
imperial: {
strength: "psi", strengthFactor: 1, // already psi
mass: "lb/yd³", massFactor: 1.6856, // kg/m³ → lb/yd³
gwp: "lb CO₂/yd³", gwpFactor: 1.6856,
cost: "$/yd³", costFactor: 1 / 1.30795, // $/m³ → $/yd³
},
};
// `UNITS` is imported from `./units.mjs` (single source of truth, also used
// by the Node-based `test/test_js_units.mjs` parity tests).
function U() { return UNITS[unitSystem]; }

// Animated unit transition
Expand Down Expand Up @@ -294,11 +288,22 @@ function buildSliders() {
// negative values are ever entered (no need for `-` key on iOS Safari).
valueInput.inputMode = "decimal";
valueInput.setAttribute("aria-label", `${shortName} value`);
valueInput.value = (currentComposition[i] * sliderDisplayFactor(col)).toFixed(1);
valueInput.value = displayCompValue(col, currentComposition[i]).toFixed(1);
valueInput.dataset.idx = i;
valueInput.dataset.col = col;
attachValueEditHandlers(valueInput, i, col, b);
label.append(nameSpan, valueInput);

// Per-row unit suffix (kg/m³ ↔ lb/yd³ on toggle; °C for Temperature).
// Wrapped in a flex container so the label keeps its two-child
// `space-between` layout (name on the left, value+unit packed on the right).
const valueWrap = document.createElement("span");
valueWrap.className = "slider-value-wrap";
const unitSpan = document.createElement("span");
unitSpan.className = "slider-unit";
unitSpan.id = `unit-${i}`;
unitSpan.textContent = sliderUnitLabel(col);
valueWrap.append(valueInput, unitSpan);
label.append(nameSpan, valueWrap);

const input = document.createElement("input");
input.type = "range";
Expand Down Expand Up @@ -378,7 +383,7 @@ function syncSliderDOM(comp, updateValues = true) {
for (const slider of _sliderInputs) {
const idx = parseInt(slider.dataset.idx);
if (updateValues) slider.value = comp[idx];
setValueDisplay(idx, (comp[idx] * sliderDisplayFactor(slider.dataset.col)).toFixed(1));
setValueDisplay(idx, displayCompValue(slider.dataset.col, comp[idx]).toFixed(1));
}
}

Expand Down Expand Up @@ -447,7 +452,7 @@ function onSliderChange(e) {
const idx = parseInt(e.target.dataset.idx);
currentComposition[idx] = parseFloat(e.target.value);
displayPreviewComp[idx] = currentComposition[idx];
const displayVal = currentComposition[idx] * sliderDisplayFactor(e.target.dataset.col);
const displayVal = displayCompValue(e.target.dataset.col, currentComposition[idx]);
setValueDisplay(idx, displayVal.toFixed(1));
_sliderActive = true;
if (_sliderIdleTimer) clearTimeout(_sliderIdleTimer);
Expand All @@ -458,14 +463,23 @@ function onSliderChange(e) {
checkExtrapolationWarning();
}

// Display factor for a slider column — temperature is never converted
function sliderDisplayFactor(colName) {
if (colName.includes("Temp")) return 1;
return U().massFactor;
// Display value for a composition column under the active unit system.
// Handles both mass (factor) and temperature (factor + offset).
function displayCompValue(colName, internal) {
return compToDisplay(colName, internal, unitSystem);
}
// Inverse of `displayCompValue`: parse a user-typed display value back to
// the model-native (kg/m³ or °C) value before clamping/storage.
function internalCompValue(colName, display) {
return compFromDisplay(colName, display, unitSystem);
}
// Unit suffix label for a slider column (delegates to `units.mjs`).
function sliderUnitLabel(colName) {
return sliderUnitLabelFor(colName, unitSystem);
}
function updateSliderLabels() {
syncSliderDOM(currentComposition, false);
// Update info rows (min/max labels)
// Update info rows (min/max labels) and per-row unit suffixes
const bounds = compositionsData.slider_bounds;
const colNames = compositionsData.column_names;
const infoRows = document.querySelectorAll("#sliders .info-row");
Expand All @@ -476,12 +490,16 @@ function updateSliderLabels() {
const b = bounds[col];
if (b.min === b.max) continue;
if (rowIdx < infoRows.length) {
const factor = sliderDisplayFactor(col);
const minDisp = (b.min * factor).toFixed(0);
const maxDisp = (b.max * factor).toFixed(0);
// Use offset-aware converter so temperature bounds render correctly
// in °F (e.g. -20°C → -4°F, 22°C → 72°F) under imperial.
const minDisp = displayCompValue(col, b.min).toFixed(0);
const maxDisp = displayCompValue(col, b.max).toFixed(0);
infoRows[rowIdx].innerHTML = `<span>${minDisp}</span><span>${maxDisp}</span>`;
rowIdx++;
}
// Refresh per-row unit suffix (kg/m³ ↔ lb/yd³, °C ↔ °F)
const unitEl = document.getElementById(`unit-${i}`);
if (unitEl) unitEl.textContent = sliderUnitLabel(col);
}
}

Expand Down Expand Up @@ -585,11 +603,12 @@ function attachValueEditHandlers(inputEl, idx, col, b) {
const parsed = parseFloat(raw);
if (!Number.isFinite(parsed)) {
// Non-numeric → revert displayed text
inputEl.value = (currentComposition[idx] * sliderDisplayFactor(col)).toFixed(1);
inputEl.value = displayCompValue(col, currentComposition[idx]).toFixed(1);
return;
}
// Convert displayed value back to internal units, then clamp
const internal = parsed / sliderDisplayFactor(col);
// Convert displayed value back to internal units, then clamp.
// For Temperature this also handles the °F → °C offset.
const internal = internalCompValue(col, parsed);
const clamped = Math.max(b.min, Math.min(b.max, internal));
// Build target from the most recent intended end state to avoid landing
// mid-animation values for sliders that are currently in flight.
Expand All @@ -602,7 +621,7 @@ function attachValueEditHandlers(inputEl, idx, col, b) {
// will overwrite, but we want the input to read correctly during the lerp
// since `setValueDisplay` skips focused elements — and this element is
// still focused if commit was triggered by Enter).
inputEl.value = (clamped * sliderDisplayFactor(col)).toFixed(1);
inputEl.value = displayCompValue(col, clamped).toFixed(1);
}
inputEl.addEventListener("focus", () => inputEl.select());
inputEl.addEventListener("keydown", (e) => {
Expand All @@ -613,14 +632,14 @@ function attachValueEditHandlers(inputEl, idx, col, b) {
} else if (e.key === "Escape") {
e.preventDefault();
// Revert without committing
inputEl.value = (currentComposition[idx] * sliderDisplayFactor(col)).toFixed(1);
inputEl.value = displayCompValue(col, currentComposition[idx]).toFixed(1);
inputEl.blur();
}
});
inputEl.addEventListener("blur", () => {
// Blur commits the edit (same as Enter), but only if the value was changed.
// If the value matches the current displayed state, do nothing.
const expected = (currentComposition[idx] * sliderDisplayFactor(col)).toFixed(1);
const expected = displayCompValue(col, currentComposition[idx]).toFixed(1);
if (inputEl.value.trim() !== expected) commit();
});
}
Expand Down
86 changes: 86 additions & 0 deletions docs/units.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Pure unit-system definitions and converters for the web UI.
//
// All native (stored) values are in metric — see `boxcrete/units.py` for the
// canonical authority over data units in this repo:
// compositions: kg/m³, strength: psi, temperature: °C,
// GWP: kg CO₂/m³, cost: $/m³, slump: inches.
//
// Imperial display layers in the UI apply the conversions below at render
// time. Composition/GWP/cost are simple linear factors; temperature has an
// offset (F = C × 9/5 + 32) and needs explicit converter functions.
//
// This module is pure (no DOM, no global state) so it can be imported by
// `ui.mjs` *and* by Node-only tests (`test/test_js_units.mjs`).

export const UNITS = {
metric: {
strength: "MPa",
strengthFactor: 1 / 145.04, // psi → MPa
mass: "kg/m³",
massFactor: 1,
gwp: "kg CO₂/m³",
gwpFactor: 1,
cost: "$/m³",
costFactor: 1,
temp: "°C",
},
imperial: {
strength: "psi",
strengthFactor: 1, // already psi
mass: "lb/yd³",
massFactor: 1.6856, // kg/m³ → lb/yd³
gwp: "lb CO₂/yd³",
gwpFactor: 1.6856,
cost: "$/yd³",
costFactor: 1 / 1.30795, // $/m³ → $/yd³ (1 yd³ = 0.7646 m³)
temp: "°F",
},
};

// Whether a column name represents a temperature column (matches the
// dataset's "Temp (C)" naming convention).
export function isTempColumn(colName) {
return colName.includes("Temp");
}

// Convert a celsius value to the active unit system's display value.
// `F = C × 9/5 + 32` for imperial; identity for metric.
// NOTE: temperature conversion is *not* a single multiplicative factor
// because of the `+32` offset — animation/interpolation code that mixes
// two unit systems linearly cannot use this in the same way as mass or
// strength. Sliders snap on unit toggle, so this is fine for them.
export function celsiusToDisplay(celsius, unitSystem) {
return unitSystem === "imperial" ? celsius * 9 / 5 + 32 : celsius;
}

// Inverse of `celsiusToDisplay`: parse a displayed value back to celsius
// for storage in the model-native composition vector.
export function displayToCelsius(display, unitSystem) {
return unitSystem === "imperial" ? (display - 32) * 5 / 9 : display;
}

// Convert a stored composition value (the model-native units: kg/m³ for
// masses, °C for temperature) to the value that should be shown to the
// user given the active unit system.
//
// Caller is responsible for excluding non-numeric columns like
// "Material Source" before invoking this.
export function compToDisplay(colName, internal, unitSystem) {
if (isTempColumn(colName)) return celsiusToDisplay(internal, unitSystem);
return internal * UNITS[unitSystem].massFactor;
}

// Inverse of `compToDisplay`. Round-trip identity:
// compFromDisplay(col, compToDisplay(col, x, u), u) === x (mod fp).
export function compFromDisplay(colName, display, unitSystem) {
if (isTempColumn(colName)) return displayToCelsius(display, unitSystem);
return display / UNITS[unitSystem].massFactor;
}

// Per-row unit suffix label: "kg/m³"/"lb/yd³" for mass, "°C"/"°F" for
// temperature. Used by the composition setter panel and updated whenever
// the unit system toggles.
export function sliderUnitLabel(colName, unitSystem) {
if (isTempColumn(colName)) return UNITS[unitSystem].temp;
return UNITS[unitSystem].mass;
}
Loading
Loading