Skip to content

Commit 31843c4

Browse files
Merge pull request #36 from facebookresearch/explorer-ux-and-ci-hardening
Explorer UX + CI Improvements
2 parents f59913c + 9164564 commit 31843c4

17 files changed

Lines changed: 631 additions & 68 deletions

.github/workflows/js-sync.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,3 +100,6 @@ jobs:
100100

101101
- name: Categorical source kernel is not interpolable
102102
run: node test/test_js_categorical_source.mjs
103+
104+
- name: Scatter filter predicate + derived quantities
105+
run: node test/test_js_filters.mjs

Makefile

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,16 @@ TRACKED_PY := $(shell (sl files 2>/dev/null || git ls-files) | grep -E '\.py$$')
6767
# untracked working-tree files don't poison the result. Black version
6868
# is pinned via pyproject.toml's [project.optional-dependencies].dev
6969
# so the formatter output is bit-for-bit identical to CI.
70+
# Hard-gate error codes. MUST stay identical to the flake8 --select list in
71+
# .github/workflows/tests.yml, otherwise `make lint` green does not imply the
72+
# CI lint job is green. This previously gated only E9,F63,F7,F82 (4 codes)
73+
# while CI gated 13 -- so E501 (long lines) and F401 (unused imports), the two
74+
# most common real failures, passed locally and broke CI.
75+
FLAKE8_SELECT = E9,E202,E226,E251,E402,E501,E741,F401,F63,F7,F811,F82,F841
76+
7077
lint:
7178
$(PYTHON) -m black --check --diff $(TRACKED_PY)
72-
$(PYTHON) -m flake8 $(TRACKED_PY) --count --select=E9,F63,F7,F82 --show-source --statistics
79+
$(PYTHON) -m flake8 $(TRACKED_PY) --count --select=$(FLAKE8_SELECT) --show-source --statistics
7380
$(PYTHON) -m flake8 $(TRACKED_PY) --count --exit-zero --statistics
7481

7582
format:
@@ -99,7 +106,8 @@ JS_TESTS = \
99106
test/test_curve_monotonicity.mjs \
100107
test/test_data_freshness.mjs \
101108
test/test_js_preview_state.mjs \
102-
test/test_js_categorical_source.mjs
109+
test/test_js_categorical_source.mjs \
110+
test/test_js_filters.mjs
103111

104112
test-js:
105113
@for t in $(JS_TESTS); do \

docs/filters.mjs

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/**
2+
* Scatter-filter logic, extracted from ui.mjs so it can be unit tested.
3+
*
4+
* These functions used to live inside `setupEventListeners` — a 400-line
5+
* closure — purely so they could capture `colNames`. That made the only part
6+
* of the filter subsystem with real correctness content (the predicate, and
7+
* the derived-quantity formulas) reachable only through Playwright. Both are
8+
* pure given their inputs, so they belong here with a fast node test.
9+
*
10+
* See test/test_js_filters.mjs.
11+
*/
12+
13+
/**
14+
* Resolve a column name to its index, throwing loudly on a miss.
15+
*
16+
* A silent fallback here would be dangerous: `indexOf` returning -1 and being
17+
* used as an index yields `undefined`, which makes every numeric comparison
18+
* false and quietly disables the filter instead of failing.
19+
*/
20+
export function colIdxStrict(colNames, name) {
21+
const i = colNames.indexOf(name);
22+
if (i < 0) {
23+
throw new Error(
24+
`filters: column ${JSON.stringify(name)} not found in ${JSON.stringify(colNames)}. ` +
25+
"Column names must match DEFAULT_X_COLUMNS in boxcrete/utils.py.",
26+
);
27+
}
28+
return i;
29+
}
30+
31+
/**
32+
* Derived quantities offered in the filter dropdown alongside raw columns.
33+
* Each `compute` takes a raw composition row and returns a scalar.
34+
*
35+
* NOTE: `paste` divides by a 6-term total that omits HRWR, whereas Python's
36+
* `_TOTAL_MASS_NAMES` (boxcrete/utils.py) includes it. HRWR is at most ~13 of
37+
* ~2400 kg/m3, so the two differ by well under 1%, but they are not the same
38+
* definition. Preserved as-is here to keep this extraction behaviour-neutral.
39+
*
40+
* @param {string[]} colNames - composition column names, in catalog order.
41+
*/
42+
export function makeComputedFilters(colNames) {
43+
const iCement = colIdxStrict(colNames, "Cement (kg/m3)");
44+
const iFlyAsh = colIdxStrict(colNames, "Fly Ash (kg/m3)");
45+
const iSlag = colIdxStrict(colNames, "Slag (kg/m3)");
46+
const iWater = colIdxStrict(colNames, "Water (kg/m3)");
47+
const iCoarse = colIdxStrict(colNames, "Coarse Aggregates (kg/m3)");
48+
const iFine = colIdxStrict(colNames, "Fine Aggregate (kg/m3)");
49+
50+
const binderOf = (c) => c[iCement] + c[iFlyAsh] + c[iSlag];
51+
52+
return [
53+
{
54+
id: "wb",
55+
label: "W/B Ratio",
56+
compute: (c) => {
57+
const b = binderOf(c);
58+
return b > 0 ? c[iWater] / b : Infinity;
59+
},
60+
},
61+
{ id: "binder", label: "Total Binder", compute: binderOf },
62+
{
63+
id: "scm",
64+
label: "SCM Replacement %",
65+
compute: (c) => {
66+
const b = binderOf(c);
67+
return b > 0 ? ((c[iFlyAsh] + c[iSlag]) / b) * 100 : 0;
68+
},
69+
},
70+
{
71+
id: "paste",
72+
label: "Paste Fraction",
73+
compute: (c) => {
74+
const paste = binderOf(c) + c[iWater];
75+
const total = paste + c[iCoarse] + c[iFine];
76+
return total > 0 ? paste / total : 0;
77+
},
78+
},
79+
];
80+
}
81+
82+
/**
83+
* Does `comp` satisfy every active filter?
84+
*
85+
* A filter is either
86+
* { colIdx, classes: Set<number> } categorical: class membership
87+
* { colIdx | computed, min, max } numeric: inclusive bounds
88+
*
89+
* Categorical columns (Material Source) are unordered, so a min/max range is
90+
* meaningless for them — "between Source A and Source B" says nothing. They
91+
* test set membership on the rounded class instead.
92+
*
93+
* @param {number[]} comp - one composition row.
94+
* @param {Array|null} filters - active filter specs; null/empty means "match all".
95+
* @returns {boolean} true when the point should stay visible.
96+
*/
97+
export function matchesFilters(comp, filters) {
98+
if (!filters || filters.length === 0) return true;
99+
for (const f of filters) {
100+
const val = f.computed ? f.computed(comp) : comp[f.colIdx];
101+
if (f.classes) {
102+
if (!f.classes.has(Math.round(val))) return false;
103+
} else if (val < f.min || val > f.max) {
104+
return false;
105+
}
106+
}
107+
return true;
108+
}

docs/model_init_worker.mjs

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/**
2+
* Off-main-thread strength-model initialization.
3+
*
4+
* `initStrengthModel` rebuilds the 670x670 training kernel and Cholesky-factors
5+
* it -- about 100 MFLOP, measured at ~70 ms on desktop arm64 and therefore
6+
* roughly 230-460 ms on a mid-range phone. Run inline it blocks first paint and
7+
* every tap for that whole window.
8+
*
9+
* The factorization itself is not meaningfully optimizable in JS: array-of-
10+
* arrays measured 38-40 ms and a flat Float64Array rewrite came out *slower*
11+
* (46-50 ms), because V8 hoists the row pointer for `L[i][k]` while flat
12+
* indexing needs two adds per inner-loop access. So the win has to come from
13+
* moving the work, not shrinking it.
14+
*
15+
* This worker runs the identical `initStrengthModel` and posts the fully
16+
* derived params back. The big buffers are handed over as transferables, so
17+
* the ~3.6 MB factor costs nothing to return.
18+
*/
19+
20+
import { initStrengthModel } from "./gp.mjs";
21+
22+
// Refuse to run outside a dedicated worker.
23+
//
24+
// This is what makes the handler below safe without an origin check, and it is
25+
// enforced rather than assumed. A dedicated worker is addressable only by the
26+
// document that constructed it -- no cross-origin window can postMessage into
27+
// it -- and its message events carry no origin to verify. Measured in
28+
// Chromium: inside a dedicated worker `event.origin` is "" and `event.source`
29+
// is null, where a window handler sees the real origin.
30+
//
31+
// The hazard the check removes is real. This file is a static asset, so if it
32+
// were ever imported into the page instead of constructed as a Worker, `self`
33+
// would be the Window and `self.onmessage` would install an unguarded
34+
// window-level message handler that any cross-origin opener could drive --
35+
// precisely the CodeQL js/missing-origin-check pattern. Failing loudly means
36+
// that misuse can never silently become a vulnerability.
37+
if (
38+
typeof DedicatedWorkerGlobalScope === "undefined" ||
39+
!(self instanceof DedicatedWorkerGlobalScope)
40+
) {
41+
throw new Error(
42+
"model_init_worker.mjs must be constructed as a dedicated Worker, not " +
43+
"imported into a document. Loading it in a window scope would install " +
44+
"an unguarded message handler.",
45+
);
46+
}
47+
48+
self.onmessage = (e) => {
49+
// Provenance gate. In a dedicated worker the only possible sender is the
50+
// parent document, and such events are dispatched with an empty origin.
51+
// Anything else means this is not the context asserted above, so drop the
52+
// message rather than act on untrusted input.
53+
if (e.origin !== "") return;
54+
55+
const params = e.data;
56+
try {
57+
initStrengthModel(params);
58+
} catch (err) {
59+
// Surface the real reason; ui.mjs falls back to synchronous init.
60+
self.postMessage({ __error: String((err && err.message) || err) });
61+
return;
62+
}
63+
64+
// Hand over the large typed arrays instead of copying them. Anything not
65+
// listed here is structure-cloned, which is fine for the small fields.
66+
const transfer = [];
67+
for (const key of ["L_flat", "X_train_flat", "alpha_f64", "_hTrain"]) {
68+
const buf = params[key] && params[key].buffer;
69+
// Guard against two views sharing one buffer -- transferring twice throws.
70+
if (buf && !transfer.includes(buf)) transfer.push(buf);
71+
}
72+
self.postMessage(params, transfer);
73+
};

docs/style.css

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -606,16 +606,31 @@ h2 {
606606
border-color: var(--accent);
607607
background: rgba(var(--card-bg-rgb), 0.6);
608608
}
609+
/* The input is the TOUCH TARGET; the visible 6 px bar is drawn by the track
610+
pseudo-elements below. Previously the element itself was the 6 px bar, which
611+
meant the whole hit area was 6 px tall -- measured 220x6 on mobile, against a
612+
WCAG 2.2 AA minimum of 24x24 and an Apple HIG recommendation of 44x44. All 8
613+
sliders failed. Separating hit area from visual lets the target grow without
614+
changing anything the user sees. */
609615
.slider-group input[type=range] {
610616
width: 100%;
611617
cursor: pointer;
612618
accent-color: var(--accent);
613619
-webkit-appearance: none;
614620
appearance: none;
621+
height: 24px;
622+
background: transparent;
623+
outline: none;
624+
}
625+
.slider-group input[type=range]::-webkit-slider-runnable-track {
626+
height: 6px;
627+
background: var(--border);
628+
border-radius: 3px;
629+
}
630+
.slider-group input[type=range]::-moz-range-track {
615631
height: 6px;
616632
background: var(--border);
617633
border-radius: 3px;
618-
outline: none;
619634
}
620635
.slider-group input[type=range]::-webkit-slider-thumb {
621636
-webkit-appearance: none;
@@ -626,6 +641,9 @@ h2 {
626641
border: 2px solid rgba(255,255,255,0.9);
627642
cursor: pointer;
628643
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
644+
/* WebKit aligns the thumb to the top of the runnable track, so pull it up
645+
by half the difference to re-center it on the 6 px bar. */
646+
margin-top: -6px;
629647
}
630648
.slider-group input[type=range]::-moz-range-thumb {
631649
width: 18px;
@@ -1507,7 +1525,12 @@ a.ref-link {
15071525
small breathing margin from the label above and info-row below. */
15081526
width: min(60vw, 220px);
15091527
display: block;
1510-
margin: 4px auto;
1528+
/* 44 px hit area (Apple HIG); the visible bar stays 6 px via the track
1529+
pseudo-element. Margin drops to 0 so the row grows by less than the
1530+
full height increase -- the taller target absorbs the gap that used to
1531+
sit between the label row and the info-row. */
1532+
height: 44px;
1533+
margin: 0 auto;
15111534
}
15121535
/* Smaller thumb on mobile reduces the intrinsic thumb-half-width inset
15131536
(was 9 px for the 18 px desktop thumb). 16 px is still a comfortable
@@ -1520,6 +1543,8 @@ a.ref-link {
15201543
.mobile-sliders-view .slider-group input[type=range]::-webkit-slider-thumb {
15211544
width: 16px;
15221545
height: 16px;
1546+
/* Re-center the smaller mobile thumb on the 6 px track. */
1547+
margin-top: -5px;
15231548
}
15241549
.mobile-sliders-view .slider-group input[type=range]::-moz-range-thumb {
15251550
width: 16px;

0 commit comments

Comments
 (0)