@wafertools/wafermap 0.27.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/AGENTS.md +10 -2
  2. package/CHANGELOG.md +366 -0
  3. package/README.md +1 -1
  4. package/dist/packages/canvas-adapter/charts/binCluster.d.ts +0 -1
  5. package/dist/packages/canvas-adapter/charts/boxplot.d.ts +8 -1
  6. package/dist/packages/canvas-adapter/charts/boxplot.js +1 -1
  7. package/dist/packages/canvas-adapter/charts/capability.d.ts +0 -1
  8. package/dist/packages/canvas-adapter/charts/correlation.d.ts +0 -1
  9. package/dist/packages/canvas-adapter/charts/histogram.d.ts +0 -1
  10. package/dist/packages/canvas-adapter/charts/histogram.js +1 -1
  11. package/dist/packages/canvas-adapter/charts/regionYieldDiagram.d.ts +0 -1
  12. package/dist/packages/canvas-adapter/charts/scatter.d.ts +6 -1
  13. package/dist/packages/canvas-adapter/charts/scatter.js +1 -1
  14. package/dist/packages/canvas-adapter/charts/trend.d.ts +7 -0
  15. package/dist/packages/canvas-adapter/charts/trend.js +1 -1
  16. package/dist/packages/canvas-adapter/hitGrid.d.ts +22 -0
  17. package/dist/packages/canvas-adapter/hitGrid.js +1 -0
  18. package/dist/packages/canvas-adapter/index.d.ts +1 -0
  19. package/dist/packages/canvas-adapter/index.js +1 -1
  20. package/dist/packages/canvas-adapter/insightsTab.d.ts +24 -2
  21. package/dist/packages/canvas-adapter/insightsTab.js +1 -1
  22. package/dist/packages/canvas-adapter/maplessSummary.d.ts +8 -1
  23. package/dist/packages/canvas-adapter/maplessSummary.js +1 -1
  24. package/dist/packages/canvas-adapter/renderWaferGallery.js +1 -1
  25. package/dist/packages/canvas-adapter/renderWaferMap.d.ts +45 -1
  26. package/dist/packages/canvas-adapter/renderWaferMap.js +1 -1
  27. package/dist/packages/canvas-adapter/summaryPanel.d.ts +15 -5
  28. package/dist/packages/canvas-adapter/summaryPanel.js +3 -3
  29. package/dist/packages/canvas-adapter/toCanvas.js +1 -1
  30. package/dist/packages/canvas-adapter/toolbar.d.ts +46 -11
  31. package/dist/packages/canvas-adapter/toolbar.js +2 -2
  32. package/dist/packages/canvas-adapter/userGuideHtml.d.ts +1 -1
  33. package/dist/packages/canvas-adapter/userGuideHtml.js +27 -16
  34. package/dist/packages/canvas-adapter/version.d.ts +2 -2
  35. package/dist/packages/canvas-adapter/version.js +1 -1
  36. package/dist/packages/canvas-adapter/warnings.d.ts +25 -0
  37. package/dist/packages/canvas-adapter/warnings.js +1 -1
  38. package/dist/packages/core/dies.d.ts +11 -0
  39. package/dist/packages/core/dies.js +1 -1
  40. package/dist/packages/renderer/binColors.d.ts +80 -0
  41. package/dist/packages/renderer/binColors.js +1 -0
  42. package/dist/packages/renderer/buildView.d.ts +82 -6
  43. package/dist/packages/renderer/buildView.js +1 -1
  44. package/dist/packages/renderer/buildWaferMap.d.ts +8 -2
  45. package/dist/packages/renderer/buildWaferMap.js +1 -1
  46. package/dist/packages/renderer/colorMap.d.ts +16 -15
  47. package/dist/packages/renderer/colorMap.js +1 -1
  48. package/dist/packages/renderer/colorSchemes.d.ts +57 -25
  49. package/dist/packages/renderer/colorSchemes.js +1 -1
  50. package/dist/packages/renderer/index.d.ts +3 -1
  51. package/dist/packages/renderer/index.js +1 -1
  52. package/dist/packages/stats/analyzeWaferLot.js +1 -1
  53. package/dist/packages/stats/analyzeWaferMap.js +1 -1
  54. package/dist/packages/stats/histogram.d.ts +28 -1
  55. package/dist/packages/stats/histogram.js +1 -1
  56. package/dist/packages/stats/population.d.ts +23 -0
  57. package/dist/packages/stats/population.js +1 -0
  58. package/dist/packages/stats/renderFindingsReport.js +10 -10
  59. package/dist/packages/stats/renderSummaryReport.js +22 -22
  60. package/dist/packages/stats/yield.js +1 -1
  61. package/package.json +5 -3
package/AGENTS.md CHANGED
@@ -19,7 +19,7 @@ loudly over guessing.
19
19
 
20
20
  ### Entry points
21
21
 
22
- - `@wafertools/wafermap` — `buildWaferMap()`, geometry, `registerColorScheme()`. Pure, no DOM, server-safe.
22
+ - `@wafertools/wafermap` — `buildWaferMap()`, geometry, `registerBinColorScheme()` / `registerValueColorScheme()`. Pure, no DOM, server-safe.
23
23
  - `@wafertools/wafermap/render` — `renderWaferMap()`, `renderWaferGallery()`, `toCanvas()`. Needs the DOM.
24
24
  - `@wafertools/wafermap/stats` — `analyzeWaferMap()`, `analyzeWaferLot()`. Pure analysis.
25
25
  - `@wafertools/wafermap/worker` — `createWafermapWorker()` for off-main-thread builds.
@@ -82,6 +82,12 @@ correctness guarantee that comes with it.
82
82
  - `retestPolicy: 'best'`/`'worst'` is pass/fail-aware via `passBins`; bin number only
83
83
  breaks ties within a category.
84
84
  - Hard bins (`hbin`) and soft bins (`sbin`) are independent number spaces. Never merge them.
85
+ - **Never pick a bin's colour from its number.** Bin colours are assigned by
86
+ `resolveBinColors` — pass bins (per `passBins`) take green pass colours, fail bins
87
+ take the rest, most populous first — and a rendered map exposes the result as
88
+ `View.binColors`. A legend or chart you build yourself must read those, or it
89
+ will name colours the map is not drawing. Bin maps and value maps have separate
90
+ schemes: `binColorScheme` and `valueColorScheme`.
85
91
  - Build once, render many: `buildWaferMap()` handles data + geometry; re-render UI
86
92
  changes through the controller's `setOptions()`, not by rebuilding.
87
93
  - `result.view` is internal. Use the promoted fields: `result.plotMode`,
@@ -151,7 +157,9 @@ it is handed, because it has no way to know which tests anyone will look at.
151
157
  | `WaferFlat`, field `flat` | `WaferNotch`, field `notch` |
152
158
  | `isInsideWaferWithFlat` | `isInsideWafer` |
153
159
  | `DieSample` / `WaferMapPoint` | `DieResult` |
154
- | `colorScheme: 'color'` | `colorScheme: 'default'` |
160
+ | `colorScheme` / `WaferViewOptions.colorScheme` | `binColorScheme` (bin maps) and `valueColorScheme` (value and stacked maps) |
161
+ | `registerColorScheme` / `getColorScheme` / `listColorSchemes` | `registerBinColorScheme` / `registerValueColorScheme` and their `get` / `list` pairs |
162
+ | `hardBinColor` / `softBinColor` / `hardBinGreyscale` | `resolveBinColors`, or `View.binColors` from a rendered map |
155
163
  | `plotMode: 'specLimit'` | `passFailDisplay: 'spec'` |
156
164
  | standalone `getDieAtPoint` | `hitTarget.getDieAtPoint` from `toCanvas()` |
157
165
  | `RenderOptions.tooltipTestLimit` | (was a no-op; nothing replaces it) |
package/CHANGELOG.md CHANGED
@@ -22,6 +22,372 @@ under `### Breaking`.
22
22
 
23
23
  ---
24
24
 
25
+ ## [0.29.0] — 2026-09-12
26
+
27
+ ### Breaking
28
+
29
+ - **The default value gradient is now Viridis, and every perceptual gradient reads low = dark,
30
+ high = light.** The blue–cyan–yellow–red "thermal" ramp is removed. Like every rainbow ramp its
31
+ lightness is not monotonic — cyan and yellow both sit near peak while blue and red are much
32
+ darker — so two different readings land at the same apparent intensity, and the fast hue turns
33
+ at cyan and yellow draw contour lines that are not in the data. On a smooth parametric map that
34
+ shows up as a bright ring around the wafer centre that no process step put there. Users reported
35
+ exactly this: being unable to judge the difference between two colours that look equally
36
+ "intense". `'jet'` keeps the rainbow family available for anyone who wants it, already labelled
37
+ as such.
38
+ - **`viridis`, `cividis`, `plasma`, `inferno` and `greyscale` were registered reversed, and are
39
+ no longer.** Each was `forValue: t => ramp(1 - t)`, putting the *bright* end of the ramp at the
40
+ *low* end of the data, while `default`, `traffic` and `jet` ran the other way. Switching gradient
41
+ therefore inverted the map. On a stacked map the effect was backwards from what the mode is for:
42
+ the healthy bulk of the wafer (fail count 0) rendered as a glowing yellow field and the edge
43
+ ring, scratch or cluster you were looking for became dark specks on it. All five now match their
44
+ matplotlib/seaborn definitions. `tests/colorSchemes.test.mjs` measures L\* across every
45
+ perceptual built-in and fails if a reversal reappears.
46
+
47
+ This also corrects the record on the change that made thermal the default in the first place
48
+ (0.14.x, "the reversed-Viridis ramp rendered high values dark purple and low values yellow — not
49
+ intuitive"). The diagnosis was right and the remedy was aimed at the wrong thing: the defect was
50
+ the `1 - t`, not Viridis, and the `1 - t` survived the change that was meant to fix it.
51
+ - **The standalone `'viridis'` gradient name is gone** — Viridis *is* `'default'`, labelled
52
+ **Default (Viridis)**. Two menu rows drawing identical maps is the same defect that removed the
53
+ old standalone "Thermal" row. `getValueColorScheme('viridis')` falls back to `'default'`, so a
54
+ persisted `'viridis'` preference still renders Viridis; it no longer appears in
55
+ `listValueColorSchemes()`.
56
+ - **`View.reverseValueScheme` is a new required field on `View`.** Code that builds a `View`
57
+ literal must supply it; code that reads one is unaffected.
58
+
59
+ ### Added
60
+
61
+ - **`reverseValueScheme` (`ViewOptions`, `WaferPreferences`) — flips whichever gradient is
62
+ selected**, offered in the Colour scheme menu as **Reverse gradient**. One flag rather than a
63
+ reversed twin of every ramp: it doubles no menu, and a gradient a host registered itself gets
64
+ the behaviour for free. It is also what makes the direction rule above exception-free —
65
+ greyscale ships low = dark like everything else, and the print habit of "more ink means more" is
66
+ one tick box rather than a documented special case.
67
+ - **`resolveValueColorFn(name, reversed)` (`renderer`) — the single read-path for a value
68
+ gradient.** Die fills, the colorbar and the mapless summary's histogram bars each looked the
69
+ gradient up independently; a flag applied at some of them and not others would show one reading
70
+ in two different colours on one screen, with the colorbar — the thing the map is read against —
71
+ the likeliest to be missed. All three now resolve through this. Any host colouring by value
72
+ should too, passing `View.valueColorScheme` and `View.reverseValueScheme` together.
73
+ - **`'mako'` value gradient** (seaborn), sampled at nine even stops from
74
+ `sns.color_palette('mako', as_cmap=True)`. The closest thing to Viridis in lightness span, with
75
+ more separation at the top end. Its sibling `crest` was measured alongside it and deliberately
76
+ not added: at L\* ~31 → ~78 against Viridis's ~10 → ~93 it has too little span for a dense die
77
+ grid — on a stacked map a scratch all but disappeared into the surrounding green. Seaborn pitches
78
+ crest at line plots, where mako's dark end gets lost; that is a different job from colouring
79
+ thousands of adjacent dies.
80
+
81
+ ## [0.28.0] — 2026-09-11
82
+
83
+ ### Breaking
84
+
85
+ - **Bin and value colours are separate preferences; `colorScheme` is removed.**
86
+ `WaferViewOptions.colorScheme` / `ViewOptions.colorScheme` → `binColorScheme` (Hard/Soft Bin maps)
87
+ and `valueColorScheme` (Test Value and the three stacked modes — a stacked-bin map is a value map,
88
+ each position's occurrence rate). The single option was reset to `'default'` on every switch into a
89
+ bin mode (the "not bin-compatible" reset, written out three times), so a value-map choice never
90
+ survived a mode switch and no host could persist one honestly. `View.colorScheme` →
91
+ `View.binColorScheme` + `View.valueColorScheme`. Both are `WaferPreferences`, reported through
92
+ `onViewOptionsChange` with category `'preference'`.
93
+ - **`registerColorScheme` / `getColorScheme` / `listColorSchemes` and `ColorScheme` are replaced by
94
+ two registries:** `registerBinColorScheme` / `getBinColorScheme` / `listBinColorSchemes`
95
+ (`BinColorScheme = { label, pass, fail }`) and `registerValueColorScheme` / `getValueColorScheme` /
96
+ `listValueColorSchemes` (`ValueColorScheme = { label, forValue }`). A host-registered bin palette
97
+ now appears in the bin-mode Palette menu — the old menu filtered bin modes by hardcoded name, so it
98
+ never could. `registerBinColorScheme` throws on an empty `pass` or `fail` list.
99
+ - **`hardBinColor`, `softBinColor`, `hardBinGreyscale` and `HARD_BIN_GREY` are removed.** They hashed
100
+ the bin number (see Fixed). Use `resolveBinColors`, or `View.binColors` from a rendered map.
101
+ - **The `'custom'` pseudo-scheme is gone.** `BinDef.color` is now a layer every bin palette honours,
102
+ on by default; `useDefinedBinColors: false` (Palette menu: *Use colours from bin definitions*) turns
103
+ it off. Definition colours used to apply only while "Custom" was selected, and the gallery switched
104
+ to it only when the host had not passed a scheme — so a host restoring a saved scheme silently
105
+ hid the colours that came with the data.
106
+ - **Value gradient names:** `'accessible'` → `'cividis'` (the bin half of the old scheme is now the
107
+ `'accessible'` bin palette, labelled *Colour-blind safe*). `'thermal'` is removed: it had the same
108
+ keypoints as `'default'` — two menu rows drawing identical maps.
109
+ - **Bin maps look different.** Both bin palettes were re-selected by measurement and colours are now
110
+ assigned by pass/fail and die count rather than by bin number (see Fixed), so images of bin maps
111
+ will not match earlier ones.
112
+ - Insights internals: `InsightsTabDeps.getColorSchemeName` → `getBinColors`;
113
+ `ScatterPanelOptions.colorScheme` → `binColors`; the unused `colorScheme` field is removed from the
114
+ bin-cluster, boxplot, capability, correlation, histogram and region-yield panel options.
115
+
116
+ ### Added
117
+
118
+ - **`resolveBinColors(dies, options)`** — the one rule for bin colour, used by the map, its legends,
119
+ the summary panels, the mapless footer and the Insights charts. Pass bins (per `passBins`) take the
120
+ palette's pass colours, failing bins its fail colours, each ranked by die count; a soft bin passes
121
+ when every die carrying it passes. Returns `BinColors` (`{ hard, soft, shared }`). Exposed on every
122
+ view as **`View.binColors`**; **`ViewOptions.binColors`** lets a host share one assignment across
123
+ several maps (`renderWaferGallery` does this for every wafer it shows, so a bin is one colour on every card).
124
+ - **`diePassStatus(die, passBins)`** (`core`) — the per-die pass rule (hard bin, else soft bin),
125
+ shared by yield, the failing-die hatch and bin colouring.
126
+ - **`bin-colors-shared` warning** — raised by the renderers when bins on the bin map on screen share
127
+ a colour (more bins than the palette has colours, or a `BinDef.color` repeating one). A gallery
128
+ states it once for all its wafers rather than per card.
129
+ - `tests/binPalettes.test.mjs` re-measures the built-in bin palettes (CIEDE2000, with simulated
130
+ deuteranopia, protanopia and tritanopia) so an edit cannot quietly erode their separation.
131
+ - `docs/api.md` now documents `standardDiameters` (§4.1.12) and the metadata helpers
132
+ `metadataDisplayValue` / `metadataCategoricalValue` / `discoverDieMetadataKeys` (§10.1). All
133
+ four were public exports that the reference never mentioned — `STANDARD_WAFER_DIAMETERS_MM`
134
+ since 0.27.0, where it shipped as the documented escape hatch for the new
135
+ `non-standard-diameter` advisory and was therefore undiscoverable outside the source; the
136
+ other three since 0.24.0.
137
+ - `scripts/check-api-claims.mjs` now also fails when a public export (per
138
+ `tests/export-surface.test.mjs`'s snapshot, the deliberate record of what is public) is never
139
+ mentioned in `docs/api.md`. The bar is low on purpose — the name appearing anywhere, even in a
140
+ code block, is enough — because this catches exports forgotten entirely, which is the failure
141
+ that actually happened, not how well each one is explained.
142
+ - `tests/insightsScopeReconciliation.test.mjs` pins scope-aware test-definition reconciliation in
143
+ **all three** Insights views. The behaviour was already correct; nothing tested it, and the
144
+ whole-load version shipped once before.
145
+ - **A boxplot leaf click now does something in a single-wafer render.** `renderWaferMap`'s
146
+ Insights tab passes no `openWafer` — the only wafer there is to open is the one already on
147
+ screen — which left the "Test value distribution" row inert, silently throwing away the other
148
+ half of the click: the selected test. New `InsightsTabDeps.focusTest`, wired by
149
+ `renderWaferMap`, switches the map behind the tab into test-value mode on the clicked test
150
+ and closes Insights. The gallery is unchanged: where `openWafer` exists it wins, since
151
+ opening the wafer already lands it on that test.
152
+ - `BoxplotPanelOptions.openActionLabel` (optional) supplies the wording for both click
153
+ affordances, so the chart says what it will actually do — "click a box to show this test on
154
+ the map" instead of "…to open that wafer". One string, not two, because the hint and the
155
+ tooltip describe one action.
156
+ - `focusTest` is consulted only for a leaf row of the sole item: with more than one wafer on
157
+ screen, "show this test on the map" would show it for a different wafer than the one
158
+ clicked. The trend panel is deliberately not wired to it — it renders an empty state below
159
+ two wafers, so that branch could never fire.
160
+ - Reported against tsmap, where a single-wafer load has exactly this gap; logged there as
161
+ WMAP_ISSUES.md #51.
162
+
163
+ - `scripts/check-doc-links.mjs` (new, wired into `npm run check`) resolves every internal
164
+ documentation link and fails on a dead one. Zensical has no link validation and no redirect
165
+ map, so a wrong anchor is not an error — the browser lands at the top of the page and the
166
+ reader concludes the docs are wrong about themselves, with nothing in the build saying a word.
167
+ It checks that link targets exist, that every `#anchor` names a heading the Markdown really
168
+ produces (using Python-Markdown's slug algorithm, the one Zensical actually runs — validated
169
+ against a real build at 385/385 anchors), that no two headings in a page slugify to the same
170
+ id, and that every `zensical.toml` nav entry resolves. The hand-written example and demo pages
171
+ are scanned too, which is how `theming.html`'s `../api.html#...` was found: the site serves
172
+ that page at `api/`, so the link had never worked. tsmap carries the same script.
173
+
174
+ ### Changed
175
+
176
+ - **"Mark failing dies" moved from Colour scheme to Overlays, and both menus gained an action
177
+ to undo themselves.** It was the one entry in Colour scheme that was not a scheme — a marker
178
+ drawn over dies, sitting among mutually exclusive palettes — while its sibling concept, the
179
+ Spec/Test pass-fail display, was already in Overlays. The two are the same judgement about the
180
+ same dies, and splitting them across two menus made neither findable from the other. Overlays
181
+ was already the right home in a second way: it already carries conditionally-disabled rows
182
+ (Reticle grid), so a bin-modes-only entry is not out of place there.
183
+ **Overlays gained "Clear overlays"** — six toggles are tedious to turn off one at a time, and
184
+ the row is greyed when nothing is on, so the menu now answers "is anything active?" without the
185
+ reader auditing every line.
186
+ **Orientation gained "Reset orientation"**, which matters more than convenience: rotation and
187
+ mirroring do not commute (`mirror ∘ rot(θ) = rot(−θ) ∘ mirror`), so a reader who has rotated
188
+ and flipped a few times cannot reliably click their way back. Reset is exactly right, and a
189
+ wafer map read in the wrong orientation is the class of mistake this library exists to prevent.
190
+ Both are greyed at the default state.
191
+ `CheckMenuRow` gained `action: true` for rows that *do* something rather than holding a state.
192
+ The first version of this change shipped them as ordinary rows, which rendered an unchecked ✓
193
+ and announced them to a screen reader as checkboxes that were off. The surface snapshot below
194
+ caught it on its first real use — the diff showed `Clear overlays [unchecked, disabled]`.
195
+ - **`scripts/ui-surface.mjs` (new) records the whole interactive surface to a committed text
196
+ file** — `docs/ui-surface.txt`, 117 controls across the single map, the gallery and the Insights
197
+ tab, with every label, accessible name, hint, checked/disabled state and menu ordering.
198
+ `npm run ui:surface` regenerates it; `npm run ui:surface:check` fails on un-recorded drift.
199
+ This library's chrome ships inside other people's applications, and every existing check asks
200
+ whether it is built correctly rather than what it says. Reading the whole surface as one page
201
+ is also what makes duplication and odd ordering visible — those are invisible while each menu
202
+ is only ever seen alone. Not in `npm run check`: it needs a browser and a built `dist/`, like
203
+ `screenshots`.
204
+ - **`WMAP_VERSION` and `WMAP_BUILD_TIME` are now public**, from `@wafertools/wafermap/render`.
205
+ They already existed — generated by `sync-version.mjs` on every build — but only reached an
206
+ internal `console.log`. A host embedding this library could tell a user which *application*
207
+ they were running and had no way to say which **engine** was underneath, and "check the browser
208
+ console" is not an answer you can give a fab engineer asking why a map looks wrong. Being
209
+ generated at build time, they describe the bundle actually loaded rather than whatever a nearby
210
+ `package.json` claims — different things whenever a host is linked to a local checkout. tsmap
211
+ shows both in its About dialog from 0.1.34.
212
+ - **The docs site gained a light/dark theme, and most of Markdown.** Zensical is bumped
213
+ 0.0.51 → 0.0.60 (nine releases; `requirements.txt`, so CI and local builds move together),
214
+ and the config now uses what it offers.
215
+ The find that prompted it: **declaring any `[project.markdown_extensions.*]` section replaces
216
+ Zensical's default set wholesale rather than merging into it.** The config set only `toc` and
217
+ `pymdownx.highlight`, which had been silently switching off `admonition`, `abbr`, `def_list`,
218
+ `attr_list`, `tasklist`, `mark`, `caret` and `tilde` for the life of the site. Nothing
219
+ reported it, because unsupported syntax renders as ordinary paragraph text — it reads as "we
220
+ don't use admonitions here", not as a fault. Verified by deleting the sections, at which point
221
+ `!!! note` and `*[ABBR]:` both started working. The full set is now listed explicitly, with
222
+ that trap written down beside it. (Mermaid was never affected: Zensical renders those fences
223
+ natively.)
224
+ New: a **light/dark/system palette toggle in the header** — the site was light-only, which sat
225
+ oddly beside a library whose entire chrome is themeable and whose own guide had a dark-theme
226
+ bug fixed in 0.26.1. `docs/stylesheets/extra.css` stopped hardcoding three light greys and now
227
+ bridges Material's palette onto wmap's `--wmap-*` custom properties, so **the live wafer-map
228
+ demos follow the theme too** rather than sitting as bright panels on a dark page — the same
229
+ trick tsmap uses for its own 16 themes.
230
+ Also enabled: `toc.follow` and `navigation.top` (a 4,150-line API reference is the case they
231
+ exist for), `navigation.tracking` so a link copied from deep in a page points there,
232
+ `navigation.footer`, `search.highlight`/`search.share`, `content.code.annotate`/`select`,
233
+ `content.tabs.link`, and `content.tooltips` fed by a shared `includes/abbreviations.md` —
234
+ domain terms only (STDF, PTR, Cpk, the bin records), since tooltipping every "API", "UI" and
235
+ "CSV" underlined most sentences and told the reader nothing. The include lives outside `docs/`
236
+ because a snippet inside it is also built as a page of its own.
237
+ Checked rather than assumed: heading ids are byte-identical across the bump, all 670 site
238
+ anchors still resolve, no CLI flag was mangled by the newly-enabled `smartsymbols` (`--tests`
239
+ and friends survive intact), and the dark palette was driven in a real browser.
240
+ - **The Quick Start's synthetic-lot generator now has one home.** The same ~20 lines existed
241
+ four times — `docs/quickstart.md`'s copy-paste example, `docs/examples/quickstart-live.html`,
242
+ and a comment-stripped copy inlined in `scripts/capture-definitions.mjs` to shoot
243
+ `quickstart-first-map.png` — all byte-identical, with nothing enforcing it. Editing the doc
244
+ would have left the "open this in your browser" page and the screenshot directly beneath it
245
+ rendering a different wafer from the code the reader had just copied, silently.
246
+ `docs/examples/quickstart-data.js` is now the source: the live page and the capture import it,
247
+ so those cannot drift by construction. The Markdown keeps its inline copy deliberately — the
248
+ snippet promises "copy this into an HTML file, no bundler required", which a local import
249
+ would break — and `scripts/check-quickstart-snippet.mjs` (wired into `npm run check`) holds it
250
+ to the fixture, with `--write` to regenerate it. It also fails if either of the other two
251
+ re-inlines the loop. The refactor is provably inert: `quickstart-first-map.png` re-captures
252
+ byte-for-byte identical.
253
+ - **The Quick Start no longer detours into partial-wafer geometry.** The "Partial data needs a
254
+ wafer centre" section — three paragraphs and a second code block, arriving immediately after
255
+ the reader's first successful map — is now a short callout that names the case, names the
256
+ `'partial-coverage'` warning, and links to the guide section that already covers it in full.
257
+ A tutorial's job is the first success; the geometry discussion is a how-to and was already
258
+ written as one. Nothing was deleted, and a "you have now…" line closes the walkthrough.
259
+ - **The Developer Guide's sections are no longer numbered.** `## 3. Loading real data from a
260
+ CSV` is now `## Loading real data from a CSV`, and all 42 links that pointed at the numbered
261
+ anchors — plus the `Guide §N` link labels in `docs/examples/manifest.json`, which name the
262
+ section they lead to instead — were moved with it. The numbering was chronological by feature
263
+ addition rather than by reading order, and it was baked into every anchor
264
+ (`#3-loading-real-data-from-a-csv`), so inserting one section silently broke every link below
265
+ it. `check-doc-links.mjs` landed first precisely so this move could be proven complete rather
266
+ than assumed. The API reference keeps its numbering for now — there the numbers are a real
267
+ hierarchical aid, not an accident of ordering.
268
+
269
+ ### Fixed
270
+
271
+ - **Implausible geometry could hang a render.** `toCanvas` sized its hit-test grid from die size
272
+ alone, so a die size wrong by orders of magnitude — a misread WCR record gave dies ~1e-8 mm
273
+ wide — asked for more cells than an array can hold: every render and resize threw
274
+ `Invalid array length`, and the map never appeared. The grid is now sized by `hitGridDims`
275
+ (internal `hitGrid.ts`), which falls back to the span for non-finite or non-positive sizes and
276
+ caps the grid at max(4096, 4 × dies) cells; a coarser grid only means a hit test looks at a few
277
+ more dies. A non-finite die position no longer indexes cell `NaN`. Found via tsmap
278
+ (WMAP_ISSUES.md #53).
279
+ - **"Lot" was used for any set of wafers, including sets spanning several lots.** A gallery or
280
+ `analyzeWaferLot` call covers whatever wafers were passed, but the panel read "Lot Summary —
281
+ 26 wafers", yield outliers were "lower than the lot median", the trend chart's reference was
282
+ the "lot mean", reports were titled "Lot Summary" / "Lot Findings Report" and the stacked
283
+ tooltip gave "% of lot" — all over a pooled multi-lot load, or one with no lot ID at all. One
284
+ rule now names the population (`stats/population.ts`): "lot" only when every wafer records the
285
+ same lot ID (`Summary — Lot LOT123 · 13 wafers`, "lot median"), otherwise the wafers
286
+ (`Summary — 26 wafers from 2 lots`, "median of all wafers"). The stacked tooltip reads
287
+ "% of stacked wafers"; two gallery messages became lot-neutral. Public API names
288
+ (`analyzeWaferLot`, `LotStatsSummary`, `lotStack`) keep "lot" in its looser sense — the
289
+ glossary now says so. Also corrected: the API reference and developer guide still described
290
+ the gallery panel's long-gone Lot/Wafers tabs, and called its button "Lot findings" rather than
291
+ **Summary panel**.
292
+ - **Different bins were drawn in the same colour.** Soft bins and the Accessible palette hashed the
293
+ bin number into a fixed list, so collisions were certain: among bins 1–16, soft bins 1 and 7 and
294
+ 4 and 6 were identical, as were Accessible bins 4 and 5 and 9 and 13, and only 75 distinct colours
295
+ covered hard bins 1–255. The hand-picked hard bins 1–14 came in near-duplicate pairs (1/12 green,
296
+ 2/9 red, 5/11 blue…), and bins 4 and 11 were ΔE 1.4 apart for a deuteranope. Colours are now
297
+ rank-assigned from measured palettes: no two bins share a colour until the palette is exhausted,
298
+ and then a warning names them. Default: 3 pass + 19 fail colours, every pair ≥ 16 ΔE00. Colour-blind
299
+ safe: 2 pass + 14 fail colours, every pair ≥ 8.8 ΔE00 under all three simulated deficiencies.
300
+ - **Bin colour contradicted `passBins`.** Bin 1 was always green and bin 2 always red, whatever
301
+ `passBins` said — with `passBins: [1, 3]` a passing bin 3 drew orange ("marginal") and a failing
302
+ bin 12 drew green. Pass colours now come from `passBins`, never the number.
303
+ - **The soft-bin failing-die hatch marked the wrong dies.** It tested the plotted *soft* bin number
304
+ against the *hard* pass-bin list. It now judges each die by `diePassStatus`, the rule yield uses.
305
+ - **`WaferMapController.getActiveLegend()` returned colours the map was not drawing.** It hashed bin
306
+ numbers directly, ignoring the selected palette, so a host legend built from it disagreed with the
307
+ map under any non-default scheme. It now reads the view's resolved colours.
308
+ - **`analyzeWaferMap` reported a "correction" for an option that was never set.**
309
+ `{ ...DEFAULT_OPTIONS, ...options }` let an explicit `undefined` overwrite the default, which
310
+ then failed the finite-number test — so the most ordinary thing a host writes (forwarding an
311
+ optional: `{ ringCount: opts.rings }` with `rings` unset) raised an `analysis-option-corrected`
312
+ advisory, and that one is not quiet: it reaches the toolbar's warning indicator and the Summary
313
+ panel's banner. Undefined keys are now dropped before merging, so "not passed" and "passed as
314
+ undefined" mean the same thing. Genuinely bad values are still corrected and still reported.
315
+ - **`buildWaferMap({ testDefs: [] })` silently lost every value plot mode** — and every test
316
+ column in the die list. An empty array is load-bearing downstream (`renderWaferGallery`'s
317
+ reconciled "kept nothing", which no fallback may override by discovering bare test numbers back
318
+ from the dies), but from a HOST it just means "I described none", which is the same statement
319
+ as omitting the field. It is now normalised to `undefined` at that one input boundary; the
320
+ gallery's own signal is unaffected.
321
+ - **The findings expand chevron stayed painted as hovered** after the first hover:
322
+ `wireControlHover` was called on it twice, and the second call snapshotted the first's hover
323
+ colours as the resting state.
324
+ - **Geometry advisories rendered as truncated prose in the collapsed warnings banner.**
325
+ `SHORT_LABEL` still keyed the removed `inferred-pitch` and had no entry for
326
+ `non-standard-diameter` or `diameter-exceeds-die-extent` — the codes that replaced it — so
327
+ every advisory 0.27.0 actually raises fell through to a 57-character truncation of its own
328
+ message. Entries added for all current codes, and a test now checks the table against the
329
+ declared code union so it cannot drift again.
330
+ - **The chrome row painted an empty band** above a map rendered with `showToolbar: false` and no
331
+ identity: it is built unconditionally and its own comment claimed it "collapses to nothing when
332
+ it holds neither", but nothing implemented that. It now hides when it holds nothing visible —
333
+ hidden children included, since `setIdentityVisible(false)` hides in place.
334
+ - **Gallery metadata-mode colours could disagree with the legend beside them.** Each card
335
+ assigned colours by index into the values on its OWN dies, while the shared legend strip
336
+ ranked the union across every card — so a wafer that never exhibited one category shifted
337
+ every later category up a colour and painted it in the colour the legend gave to another.
338
+ New `ViewOptions.metadataValueOrder` (`{ key, values }`, applied only when `key` matches
339
+ `activeMetadataKey`, mirroring `valueRange`'s `{ test, range }` guard) is forwarded by
340
+ `renderWaferMap` and set automatically by `renderWaferGallery`, so every card colours from one
341
+ lot-wide list. Values a card has but the list doesn't are appended, never dropped.
342
+ - The ordering is now one implementation (`collectMetadataValues`) shared by the maps and the
343
+ legend, and the legend stopped deriving its values with `String(raw)` where the maps use
344
+ `metadataCategoricalValue` — a second divergence in the same place, visible on numeric
345
+ metadata.
346
+ - A standalone `renderWaferMap` is unchanged: with no order supplied it still derives one from
347
+ its own dies, which is correct for one wafer.
348
+ - **The histogram resolved an unset "axis includes limits" preference two different ways.**
349
+ Grouped (faceted) treated it as off; ungrouped derived it from the data. The same test with no
350
+ preference therefore included the spec limits in the axis in one view and not the other — the
351
+ range moved under the reader while the toggle stayed put. Both branches now derive it from
352
+ their own population via `shouldIncludeLimitsByDefault`.
353
+ - **The histogram's "Clip outliers" toggle did nothing in the grouped view.** It was rendered
354
+ there but `buildTestHistogramSeries` had no way to take a clip range, so the control applied to
355
+ the ungrouped branch only — in exactly the view where one wild reading does the most damage,
356
+ compressing every group's buckets at once. The builder now takes the same optional `clip` its
357
+ ungrouped twin has, with the same contract (narrows where limits widen; no statistic anywhere
358
+ is computed from a clipped population).
359
+ - **A grouped histogram could kill the Insights rebuild on a real lot.** The faceted axis range
360
+ used `Math.min(...values)`, which passes every die value as a separate argument; ~250k values
361
+ (25 wafers × 10k dies) throws `RangeError: Maximum call stack size exceeded`. Replaced with
362
+ `testValueExtent`, a loop that also avoids materialising the pooled array. The ungrouped branch
363
+ had the same spread and is fixed too.
364
+ - **`summaryPanel: { placement: 'top' | 'bottom' }` clipped its own content silently.** Those
365
+ placements take a fixed 180px band with `overflowY: hidden`, which most wafers' content
366
+ exceeds, so the last visible row was cut mid-line with nothing indicating more existed. All
367
+ placements now scroll, the way `'right'`/`'left'` already did. (Whether this content suits a
368
+ wide-short band at all is a separate open question — see TODO.md.)
369
+ - **Closing and reopening Insights no longer discards the shared axis toggles.** `axisPrefs`
370
+ ("axis includes limits" / "clip outliers") lived inside `renderDistributionsSection`, which
371
+ `render()` rebuilds — so a scope change, or simply toggling Insights off and on, silently
372
+ reverted the user's choice to the default. It now sits at tab level beside the selected test
373
+ and group scope, which were already lifted for the same reason. Affects the gallery too.
374
+ - **The gallery no longer re-renders every card on every toolbar change.**
375
+ `syncSharedMetadataOrder` pushed `metadataValueOrder: undefined` to each card even outside
376
+ `metadata` mode, and each push costs a full view rebuild. It now returns early when there is
377
+ nothing to change, matching `syncSharedValueRange`.
378
+ - **`renderWaferMap`'s Insights view no longer jumps back to the top on reopen.** The tab's
379
+ scroll offset is remembered on close and re-applied over a short window after the rebuild —
380
+ one assignment is not enough, because the cards grow to their measured content over the
381
+ following frames, so the first write is clamped to a container that is still short (212px of
382
+ a requested 300px, measured) and the layout pass can then reset it to 0. A position past the
383
+ target stops the retries: that is the user scrolling, and they own it from then on.
384
+ (Gallery-side, the host page owns the scroller, so there is nothing for the library to
385
+ restore.)
386
+ - Corrected `charts/boxplot.ts`'s header comment, which still said click-to-open-wafer was
387
+ unimplemented long after it shipped.
388
+
389
+ ---
390
+
25
391
  ## [0.27.0] — 2026-09-09
26
392
 
27
393
  ### Breaking
package/README.md CHANGED
@@ -72,7 +72,7 @@ export. Add `analyzeWaferMap` for the findings and summary panel, swap in
72
72
 
73
73
  The API reference is long because it documents every option, not because you need
74
74
  them: [tsmap](https://github.com/wafertools/tsmap), a complete cross-platform desktop
75
- application built on this library, imports **10** of its ~100 exports. Read the
75
+ application built on this library, imports **14** of its ~100 exports. Read the
76
76
  [Quick Start](https://wafertools.github.io/wafermap/quickstart/) first and treat the
77
77
  [API reference](https://wafertools.github.io/wafermap/api/) as something to search,
78
78
  not to read.
@@ -6,7 +6,6 @@ export interface BinClusterPanelOptions {
6
6
  key: string;
7
7
  items: BinItem[];
8
8
  }[];
9
- colorScheme?: string;
10
9
  onSaveImage?: SaveImageHandler;
11
10
  /** Document to build this panel's DOM into. Default `document` — pass the
12
11
  * host's own `ownerDocument` when the container might live in a
@@ -11,7 +11,6 @@ export interface BoxplotPanelOptions {
11
11
  items: BoxplotPanelItem[];
12
12
  testDefs: TestDef[];
13
13
  selectedTestNumber?: number;
14
- colorScheme?: string;
15
14
  onSaveImage?: SaveImageHandler;
16
15
  /**
17
16
  * When the Analysis tab's "Group by" is active, this panel shows one
@@ -44,6 +43,14 @@ export interface BoxplotPanelOptions {
44
43
  * same test in value mode instead of defaulting to hard-bin mode.
45
44
  * Never called for a pooled group-overview row (that drills instead). */
46
45
  onOpen?: (waferIndex: number, testNumber: number) => void;
46
+ /** What `onOpen` will actually do, in the user's words — substituted into
47
+ * both click affordances ("click a box to …" / "click to …"). Default
48
+ * wording describes opening that wafer, which is what a gallery host does;
49
+ * a single-wafer host, where there is no other wafer to open and the click
50
+ * instead shows the selected test on the map it already has, passes its own
51
+ * (see `InsightsTabDeps.focusTest`). One string rather than two because the
52
+ * two affordances describe one action — if they can disagree, they will. */
53
+ openActionLabel?: string;
47
54
  /** Document to build this panel's DOM into. Default `document` — pass the
48
55
  * host's own `ownerDocument` when the container might live in a
49
56
  * different document (e.g. a gallery card detached into its own popup
@@ -1 +1 @@
1
- import{buildTestBoxplotData as je}from"../../stats/boxplot.js";import{SPACE as Be,fontPx as X,FONT as Ke,CLR as Je}from"../toolbar.js";import{fmt as de}from"../../renderer/fmt.js";import{QUANTITY as De}from"./palette.js";import{cardShell as Ze,observeResize as et,makeTooltip as tt,positionChartTooltip as nt,makeBackButton as ot,makeLinkedTestSelect as it,makeToggle as lt,makeLinkedAxisPrefs as st,renderEmptyState as Ie,growCardToFitContent as at,resolveChartCanvasColors as rt,makeAxisFormat as ct,resolveAxisRange as ft,shouldIncludeLimitsByDefault as ut,drawOffAxisLimits as mt,limitLabelSide as dt,PADDING as M,VALUE_WIDTH as xt,prepareCanvas as pt}from"./chartShell.js";const k=24,L=5,Re=110,ht=12,He=20;export function renderBoxplotPanel(y){const{title:J="Test value distribution",items:Oe,testDefs:xe,onSaveImage:We,groups:m,groupLabelText:Z="group",onOpen:N}=y,{card:a,heading:Ee,body:d,controlsRow:q}=Ze(J,We,y.ownerDocument);a.style.flex="0 0 auto",d.style.flex="0 0 auto",a.style.alignSelf="start";const ee=xe.filter(n=>n.testNumber!==void 0);let v=y.selectedTestNumber??ee[0]?.testNumber??null,te=!1,Y=0,x=null,I=null,ne=null,pe=null;function Pe(){const n=!m||m.length===0?"":x??"\0overview";if(ne&&pe===n)return ne;const u=!m||m.length===0?Oe:x!==null?m.find(h=>h.key===x)?.items??[]:m.map(h=>({label:h.key,dies:h.items.flatMap(c=>c.dies??[])}));return ne=u,pe=n,u}function he(){x!==null&&!I?(I=ot(()=>{ge(null),y.onGroupChange?.(null)},a.ownerDocument),q.appendChild(I)):x===null&&I&&(I.remove(),I=null),Ee.textContent=x!==null?`${J} \u2014 ${Z}: ${x}`:J}function ge(n){return x===n||n!==null&&!(m??[]).some(u=>u.key===n)?!1:(x=n,he(),Q(),C(),!0)}const be=it(ee,v,n=>{v=n,C(),y.onTestChange?.(n)},{maxWidth:"240px",emptyText:"No parametric tests",ownerDocument:a.ownerDocument}),_e=be.el;q.appendChild(_e),q.appendChild(lt("Log scale",te,n=>{te=n,C()},a.ownerDocument));const oe=a.ownerDocument.createElement("span");Object.assign(oe.style,{display:"inline-flex",gap:Be.lg,alignItems:"center"}),q.appendChild(oe);const ie=st(oe,y.axisPrefs,n=>{y.onAxisPrefsChange?.(n),C()},a.ownerDocument),le=a.ownerDocument.createElement("div");Object.assign(le.style,{color:Je.label,fontSize:Ke.body,marginBottom:Be.sm}),a.insertBefore(le,d);function Q(){const n=!!m&&m.length>0&&x===null,u=[];n?u.push(`click a ${Z}'s box to see it by wafer`):N&&u.push("click a box to open that wafer");const h=u.length?`${u[0][0].toUpperCase()}${u[0].slice(1)} \xB7 `:"";le.textContent=`${h}box = Q1\u2013Q3, line = median, whiskers = min/max \xB7 value shown is the median`+(Y?` \xB7 axis clipped, ${Y} value${Y===1?"":"s"} outside`:"")}Q(),he();const H=tt(a);let se=null;function C(){const{includeLimits:n,clipOutliers:u}=ie.get();if(Q(),d.innerHTML="",ee.length===0||v===null){Ie(d,"No parametric test data available for box plots.");return}const h=Pe(),c=je(h,v),ae=xe.find(t=>t.testNumber===v),U=ae?.unit,z=ae?.limitLow,F=ae?.limitHigh;if(c.every(t=>t.count===0)){Ie(d,"No parametric test data available for box plots.");return}const p=a.ownerDocument.createElement("canvas");p.style.display="block",p.style.cursor="default",d.appendChild(p);const Te=M*2+Math.min(c.length,ht)*(k+L)+He;d.style.maxHeight=`${Te}px`,d.style.overflowY="auto",d.style.scrollbarGutter="stable";let O=-1;const re=c.filter(t=>t.count>0),ye=Math.min(...re.map(t=>t.min)),ve=Math.max(...re.map(t=>t.max)),we=n??ut(ye,ve,z,F);ie.sync(we,z!==void 0||F!==void 0);const W=ft({dataMin:ye,dataMax:ve,limitLow:z,limitHigh:F,includeLimits:we,clipOutliers:u,values:re.flatMap(t=>[t.min,t.q1,t.median,t.q3,t.max])});Y=W.clippedCount,Q();const R=W.lo,ce=W.hi,Me=ce-R||1,V=te&&R>0,fe=V?Math.log10(R):0,ke=(V?Math.log10(ce):0)-fe||1,ue=ct(Math.max(Math.abs(R),Math.abs(ce)),U);function Ne(){const t=M+Re,i=p.clientWidth-t-xt-M;return{plotX:t,plotMaxWidth:Math.max(10,i)}}function S(t,i,s){return V?i+(Math.log10(t)-fe)/ke*s:i+(t-R)/Me*s}function qe(){const t=[];for(let i=0;i<=4;i++)t.push(V?Math.pow(10,fe+ke*i/4):R+Me*i/4);return t}function j(){const t=rt(a),i=d.clientWidth,s=M*2+c.length*(k+L)+He,A=pt(p,a,i,s);if(!A)return;const{ctx:e}=A;e.font=`${X(-1)}px system-ui, sans-serif`,e.textBaseline="middle";const{plotX:l,plotMaxWidth:f}=Ne();c.forEach((o,g)=>{const T=M+g*(k+L),r=T+k/2;g===O&&(e.fillStyle=t.bgHover,e.fillRect(0,T-L/2,i,k+L)),e.fillStyle=t.text,e.textAlign="right";const Qe=o.label.length>16?`${o.label.slice(0,15)}\u2026`:o.label;if(e.fillText(Qe,M+Re-8,r),o.count===0){e.fillStyle=t.textMuted,e.textAlign="left",e.fillText("no data",l,r);return}const Ue=l+f,P=Ve=>Math.min(Ue,Math.max(l,Ve)),Se=S(o.min,l,f),Ae=S(o.max,l,f),$=P(Se),_=P(S(o.q1,l,f)),$e=P(S(o.median,l,f)),me=P(S(o.q3,l,f)),B=P(Ae),ze=Se<$,Fe=Ae>B,D=T+3,G=T+k-3;e.strokeStyle=t.textMuted,e.lineWidth=1,e.beginPath(),e.moveTo($,r),e.lineTo(_,r),e.moveTo(me,r),e.lineTo(B,r);const w=3;ze?(e.moveTo($+w,r-w),e.lineTo($,r),e.lineTo($+w,r+w)):(e.moveTo($,D),e.lineTo($,G)),Fe?(e.moveTo(B-w,r-w),e.lineTo(B,r),e.lineTo(B-w,r+w)):(e.moveTo(B,D),e.lineTo(B,G)),e.stroke(),e.fillStyle=De,e.globalAlpha=.35,e.fillRect(_,D,Math.max(1,me-_),G-D),e.globalAlpha=1,e.strokeStyle=De,e.strokeRect(_,D,Math.max(1,me-_),G-D),e.strokeStyle=t.text,e.lineWidth=2,e.beginPath(),e.moveTo($e,D),e.lineTo($e,G),e.stroke(),e.lineWidth=1,e.fillStyle=t.textMuted,e.textAlign="left",e.fillText(de(o.median,U,"engineering"),l+f+10,r)});const b=M+c.length*(k+L);e.font=`${X(-1)}px system-ui, sans-serif`,mt(e,W.offAxis,{left:l,right:l+f,top:0,bottom:b},"horizontal",t.limitLine,o=>de(o,U,"engineering"));const Ye=new Set(W.offAxis.map(o=>o.value));for(const[o,g]of[[z,"LSL"],[F,"USL"]]){if(o===void 0||Ye.has(o))continue;const T=S(o,l,f);e.strokeStyle=t.limitLine,e.setLineDash([3,3]),e.lineWidth=1,e.beginPath(),e.moveTo(T,0),e.lineTo(T,b),e.stroke(),e.setLineDash([]),e.fillStyle=t.limitLine;const r=dt(T,e.measureText(g).width,l,l+f,g==="LSL");e.textAlign=r<0?"right":"left",e.textBaseline="bottom",e.fillText(g,T+r*3,b-1)}e.font=`${X(-1)}px system-ui, sans-serif`,e.strokeStyle=t.border,e.lineWidth=1,e.beginPath(),e.moveTo(l,b),e.lineTo(l+f,b),e.stroke(),e.fillStyle=t.textMuted,e.textAlign="center",e.textBaseline="top";for(const o of qe()){const g=S(o,l,f);e.beginPath(),e.moveTo(g,b),e.lineTo(g,b+4),e.stroke(),e.fillText(ue.tick(o),g,b+6)}ue.unitLabel&&(e.textAlign="left",e.font=`${X(-1)}px system-ui, sans-serif`,e.fillText(`(${ue.unitLabel})`,l+f+22,b+6),e.font=`${X(-1)}px system-ui, sans-serif`),e.textBaseline="middle"}function Le(t){const i=Math.floor((t-M+L/2)/(k+L));return i>=0&&i<c.length?i:-1}function E(t){return de(t,U,"engineering")}const K=!!m&&m.length>0&&x===null,Ce=t=>!K&&!!N&&h[t]?.waferIndex!==void 0;p.addEventListener("mousemove",t=>{const i=p.getBoundingClientRect(),s=Le(t.clientY-i.top),A=s>=0&&c[s].count>0&&(K||Ce(s));if(s!==O&&(O=s,p.style.cursor=A?"pointer":"default",j()),s>=0&&c[s].count>0){const e=c[s],l=K?`<br><em>click to see this ${Z} by wafer</em>`:Ce(s)?"<br><em>click to open this wafer</em>":"";H.innerHTML=`<strong>${e.label}</strong> (${e.count} dies)<br>max ${E(e.max)}<br>q3 ${E(e.q3)}<br>median ${E(e.median)}<br>q1 ${E(e.q1)}<br>min ${E(e.min)}${l}`,H.style.display="block",nt(H,a,t.clientX,t.clientY)}else H.style.display="none"}),p.addEventListener("mouseleave",()=>{O!==-1&&(O=-1,j()),H.style.display="none"}),p.addEventListener("click",t=>{const i=p.getBoundingClientRect(),s=Le(t.clientY-i.top);if(s===-1||c[s].count===0)return;if(K){const e=c[s].label;ge(e),y.onGroupChange?.(e);return}const A=h[s]?.waferIndex;N&&A!==void 0&&v!==null&&N(A,v)}),se?.disconnect(),se=et(a,()=>j()),j(),at(a,d,Te)}C();function Ge(n){be.set(n)&&(v=n,C())}function Xe(n){ie.set(n)&&C()}return{card:a,setTest:Ge,setAxisPrefs:Xe,destroy:()=>se?.disconnect()}}
1
+ import{buildTestBoxplotData as Je}from"../../stats/boxplot.js";import{SPACE as Be,fontPx as X,FONT as Ze,CLR as et}from"../toolbar.js";import{fmt as de}from"../../renderer/fmt.js";import{QUANTITY as De}from"./palette.js";import{cardShell as tt,observeResize as nt,makeTooltip as ot,positionChartTooltip as it,makeBackButton as lt,makeLinkedTestSelect as st,makeToggle as at,makeLinkedAxisPrefs as rt,renderEmptyState as Ie,growCardToFitContent as ct,resolveChartCanvasColors as ft,makeAxisFormat as ut,resolveAxisRange as mt,shouldIncludeLimitsByDefault as dt,drawOffAxisLimits as xt,limitLabelSide as pt,PADDING as L,VALUE_WIDTH as ht,prepareCanvas as gt}from"./chartShell.js";const M=24,k=5,Re=110,bt=12,He=20;export function renderBoxplotPanel(h){const{title:J="Test value distribution",items:Oe,testDefs:xe,onSaveImage:We,groups:m,groupLabelText:Z="group",onOpen:N}=h,Ee=h.openActionLabel??"open that wafer",Pe=h.openActionLabel??"open this wafer",{card:a,heading:_e,body:d,controlsRow:q}=tt(J,We,h.ownerDocument);a.style.flex="0 0 auto",d.style.flex="0 0 auto",a.style.alignSelf="start";const ee=xe.filter(n=>n.testNumber!==void 0);let v=h.selectedTestNumber??ee[0]?.testNumber??null,te=!1,Y=0,x=null,I=null,ne=null,pe=null;function Ge(){const n=!m||m.length===0?"":x??"\0overview";if(ne&&pe===n)return ne;const u=!m||m.length===0?Oe:x!==null?m.find(g=>g.key===x)?.items??[]:m.map(g=>({label:g.key,dies:g.items.flatMap(c=>c.dies??[])}));return ne=u,pe=n,u}function he(){x!==null&&!I?(I=lt(()=>{ge(null),h.onGroupChange?.(null)},a.ownerDocument),q.appendChild(I)):x===null&&I&&(I.remove(),I=null),_e.textContent=x!==null?`${J} \u2014 ${Z}: ${x}`:J}function ge(n){return x===n||n!==null&&!(m??[]).some(u=>u.key===n)?!1:(x=n,he(),Q(),C(),!0)}const be=st(ee,v,n=>{v=n,C(),h.onTestChange?.(n)},{maxWidth:"240px",emptyText:"No parametric tests",ownerDocument:a.ownerDocument}),Xe=be.el;q.appendChild(Xe),q.appendChild(at("Log scale",te,n=>{te=n,C()},a.ownerDocument));const oe=a.ownerDocument.createElement("span");Object.assign(oe.style,{display:"inline-flex",gap:Be.lg,alignItems:"center"}),q.appendChild(oe);const ie=rt(oe,h.axisPrefs,n=>{h.onAxisPrefsChange?.(n),C()},a.ownerDocument),le=a.ownerDocument.createElement("div");Object.assign(le.style,{color:et.label,fontSize:Ze.body,marginBottom:Be.sm}),a.insertBefore(le,d);function Q(){const n=!!m&&m.length>0&&x===null,u=[];n?u.push(`click a ${Z}'s box to see it by wafer`):N&&u.push(`click a box to ${Ee}`);const g=u.length?`${u[0][0].toUpperCase()}${u[0].slice(1)} \xB7 `:"";le.textContent=`${g}box = Q1\u2013Q3, line = median, whiskers = min/max \xB7 value shown is the median`+(Y?` \xB7 axis clipped, ${Y} value${Y===1?"":"s"} outside`:"")}Q(),he();const H=ot(a);let se=null;function C(){const{includeLimits:n,clipOutliers:u}=ie.get();if(Q(),d.innerHTML="",ee.length===0||v===null){Ie(d,"No parametric test data available for box plots.");return}const g=Ge(),c=Je(g,v),ae=xe.find(t=>t.testNumber===v),U=ae?.unit,z=ae?.limitLow,F=ae?.limitHigh;if(c.every(t=>t.count===0)){Ie(d,"No parametric test data available for box plots.");return}const p=a.ownerDocument.createElement("canvas");p.style.display="block",p.style.cursor="default",d.appendChild(p);const Te=L*2+Math.min(c.length,bt)*(M+k)+He;d.style.maxHeight=`${Te}px`,d.style.overflowY="auto",d.style.scrollbarGutter="stable";let O=-1;const re=c.filter(t=>t.count>0),ye=Math.min(...re.map(t=>t.min)),ve=Math.max(...re.map(t=>t.max)),we=n??dt(ye,ve,z,F);ie.sync(we,z!==void 0||F!==void 0);const W=mt({dataMin:ye,dataMax:ve,limitLow:z,limitHigh:F,includeLimits:we,clipOutliers:u,values:re.flatMap(t=>[t.min,t.q1,t.median,t.q3,t.max])});Y=W.clippedCount,Q();const R=W.lo,ce=W.hi,Le=ce-R||1,V=te&&R>0,fe=V?Math.log10(R):0,Me=(V?Math.log10(ce):0)-fe||1,ue=ut(Math.max(Math.abs(R),Math.abs(ce)),U);function Ye(){const t=L+Re,i=p.clientWidth-t-ht-L;return{plotX:t,plotMaxWidth:Math.max(10,i)}}function S(t,i,s){return V?i+(Math.log10(t)-fe)/Me*s:i+(t-R)/Le*s}function Qe(){const t=[];for(let i=0;i<=4;i++)t.push(V?Math.pow(10,fe+Me*i/4):R+Le*i/4);return t}function j(){const t=ft(a),i=d.clientWidth,s=L*2+c.length*(M+k)+He,A=gt(p,a,i,s);if(!A)return;const{ctx:e}=A;e.font=`${X(-1)}px system-ui, sans-serif`,e.textBaseline="middle";const{plotX:l,plotMaxWidth:f}=Ye();c.forEach((o,b)=>{const y=L+b*(M+k),r=y+M/2;b===O&&(e.fillStyle=t.bgHover,e.fillRect(0,y-k/2,i,M+k)),e.fillStyle=t.text,e.textAlign="right";const ze=o.label.length>16?`${o.label.slice(0,15)}\u2026`:o.label;if(e.fillText(ze,L+Re-8,r),o.count===0){e.fillStyle=t.textMuted,e.textAlign="left",e.fillText("no data",l,r);return}const Fe=l+f,P=Ke=>Math.min(Fe,Math.max(l,Ke)),Se=S(o.min,l,f),Ae=S(o.max,l,f),$=P(Se),_=P(S(o.q1,l,f)),$e=P(S(o.median,l,f)),me=P(S(o.q3,l,f)),B=P(Ae),Ve=Se<$,je=Ae>B,D=y+3,G=y+M-3;e.strokeStyle=t.textMuted,e.lineWidth=1,e.beginPath(),e.moveTo($,r),e.lineTo(_,r),e.moveTo(me,r),e.lineTo(B,r);const w=3;Ve?(e.moveTo($+w,r-w),e.lineTo($,r),e.lineTo($+w,r+w)):(e.moveTo($,D),e.lineTo($,G)),je?(e.moveTo(B-w,r-w),e.lineTo(B,r),e.lineTo(B-w,r+w)):(e.moveTo(B,D),e.lineTo(B,G)),e.stroke(),e.fillStyle=De,e.globalAlpha=.35,e.fillRect(_,D,Math.max(1,me-_),G-D),e.globalAlpha=1,e.strokeStyle=De,e.strokeRect(_,D,Math.max(1,me-_),G-D),e.strokeStyle=t.text,e.lineWidth=2,e.beginPath(),e.moveTo($e,D),e.lineTo($e,G),e.stroke(),e.lineWidth=1,e.fillStyle=t.textMuted,e.textAlign="left",e.fillText(de(o.median,U,"engineering"),l+f+10,r)});const T=L+c.length*(M+k);e.font=`${X(-1)}px system-ui, sans-serif`,xt(e,W.offAxis,{left:l,right:l+f,top:0,bottom:T},"horizontal",t.limitLine,o=>de(o,U,"engineering"));const Ue=new Set(W.offAxis.map(o=>o.value));for(const[o,b]of[[z,"LSL"],[F,"USL"]]){if(o===void 0||Ue.has(o))continue;const y=S(o,l,f);e.strokeStyle=t.limitLine,e.setLineDash([3,3]),e.lineWidth=1,e.beginPath(),e.moveTo(y,0),e.lineTo(y,T),e.stroke(),e.setLineDash([]),e.fillStyle=t.limitLine;const r=pt(y,e.measureText(b).width,l,l+f,b==="LSL");e.textAlign=r<0?"right":"left",e.textBaseline="bottom",e.fillText(b,y+r*3,T-1)}e.font=`${X(-1)}px system-ui, sans-serif`,e.strokeStyle=t.border,e.lineWidth=1,e.beginPath(),e.moveTo(l,T),e.lineTo(l+f,T),e.stroke(),e.fillStyle=t.textMuted,e.textAlign="center",e.textBaseline="top";for(const o of Qe()){const b=S(o,l,f);e.beginPath(),e.moveTo(b,T),e.lineTo(b,T+4),e.stroke(),e.fillText(ue.tick(o),b,T+6)}ue.unitLabel&&(e.textAlign="left",e.font=`${X(-1)}px system-ui, sans-serif`,e.fillText(`(${ue.unitLabel})`,l+f+22,T+6),e.font=`${X(-1)}px system-ui, sans-serif`),e.textBaseline="middle"}function ke(t){const i=Math.floor((t-L+k/2)/(M+k));return i>=0&&i<c.length?i:-1}function E(t){return de(t,U,"engineering")}const K=!!m&&m.length>0&&x===null,Ce=t=>!K&&!!N&&g[t]?.waferIndex!==void 0;p.addEventListener("mousemove",t=>{const i=p.getBoundingClientRect(),s=ke(t.clientY-i.top),A=s>=0&&c[s].count>0&&(K||Ce(s));if(s!==O&&(O=s,p.style.cursor=A?"pointer":"default",j()),s>=0&&c[s].count>0){const e=c[s],l=K?`<br><em>click to see this ${Z} by wafer</em>`:Ce(s)?`<br><em>click to ${Pe}</em>`:"";H.innerHTML=`<strong>${e.label}</strong> (${e.count} dies)<br>max ${E(e.max)}<br>q3 ${E(e.q3)}<br>median ${E(e.median)}<br>q1 ${E(e.q1)}<br>min ${E(e.min)}${l}`,H.style.display="block",it(H,a,t.clientX,t.clientY)}else H.style.display="none"}),p.addEventListener("mouseleave",()=>{O!==-1&&(O=-1,j()),H.style.display="none"}),p.addEventListener("click",t=>{const i=p.getBoundingClientRect(),s=ke(t.clientY-i.top);if(s===-1||c[s].count===0)return;if(K){const e=c[s].label;ge(e),h.onGroupChange?.(e);return}const A=g[s]?.waferIndex;N&&A!==void 0&&v!==null&&N(A,v)}),se?.disconnect(),se=nt(a,()=>j()),j(),ct(a,d,Te)}C();function Ne(n){be.set(n)&&(v=n,C())}function qe(n){ie.set(n)&&C()}return{card:a,setTest:Ne,setAxisPrefs:qe,destroy:()=>se?.disconnect()}}
@@ -5,7 +5,6 @@ export interface CapabilityPanelOptions {
5
5
  title?: string;
6
6
  items: CapabilityItem[];
7
7
  testDefs: TestDef[];
8
- colorScheme?: string;
9
8
  onSaveImage?: SaveImageHandler;
10
9
  /** Test to mark as the section's current one, if any. */
11
10
  selectedTestNumber?: number;
@@ -14,7 +14,6 @@ export interface CorrelationPanelOptions {
14
14
  title?: string;
15
15
  items: CorrelationItem[];
16
16
  testDefs: TestDef[];
17
- colorScheme?: string;
18
17
  onSaveImage?: SaveImageHandler;
19
18
  /**
20
19
  * Grouping is NOT this panel's concern — the Insights tab owns one "Show:"
@@ -12,7 +12,6 @@ export interface HistogramPanelOptions {
12
12
  items: HistogramItem[];
13
13
  testDefs: TestDef[];
14
14
  selectedTestNumber?: number;
15
- colorScheme?: string;
16
15
  onSaveImage?: SaveImageHandler;
17
16
  /**
18
17
  * When the Analysis tab's "Group by" is active, this panel switches to an
@@ -1 +1 @@
1
- import{buildTestHistogramData as Kt,collectTestValues as Ot,buildTestHistogramSeries as Rt}from"../../stats/histogram.js";import{SPACE as et,fontPx as X,FONT as vt,CLR as wt}from"../toolbar.js";import{fmt as ht}from"../../renderer/fmt.js";import{QUANTITY as Ft,categorical as zt}from"./palette.js";import{cardShell as Gt,observeResize as kt,makeTooltip as Vt,attachChartTip as jt,positionChartTooltip as Ht,makeLinkedTestSelect as _t,makeWaferSelect as Ut,makeLinkedAxisPrefs as Xt,renderEmptyState as yt,chartFillHeight as At,applyCanvasFlow as Ct,makeAxisFormat as $t,PADDING as at,robustFence as Yt,shouldIncludeLimitsByDefault as Qt,drawOffAxisLimits as qt,resolveAxisRange as Jt,chartSwatchCss as Zt,makeSeriesLegendItem as te,prepareCanvas as Pt}from"./chartShell.js";const Mt=230,Wt=36,gt=18;function Dt(s,N,Y,Q,ft,E,a,y=4){const q=Math.max(1,E/y),nt=Math.pow(10,Math.floor(Math.log10(q))),g=q/nt,J=(g<=1?1:g<=2?2:g<=5?5:10)*nt,Z=Q-Y;s.save(),s.font=`${X(-1)}px system-ui, sans-serif`,s.textAlign="right",s.textBaseline="middle";for(let K=0;K<=E+1e-9;K+=J){const ot=Q-K/(E||1)*Z;s.strokeStyle=a.grid,s.lineWidth=1,s.beginPath(),s.moveTo(N,ot),s.lineTo(N+ft,ot),s.stroke(),s.fillStyle=a.text,s.fillText(String(Math.round(K)),N-4,ot)}s.strokeStyle=a.axis,s.lineWidth=1,s.beginPath(),s.moveTo(N,Y),s.lineTo(N,Q),s.stroke(),s.restore()}export function renderHistogramPanel(s){const{title:N="Test value distribution",items:Y,testDefs:Q,onSaveImage:ft,groups:E}=s,{card:a,body:y,controlsRow:q}=Gt(N,ft,s.ownerDocument),nt=Q.filter(l=>l.testNumber!==void 0);let g=s.selectedTestNumber??nt[0]?.testNumber??null,J=null,Z=0;const K=_t(nt,g,l=>{g=l,O(),s.onTestChange?.(l)},{maxWidth:"200px",ownerDocument:a.ownerDocument}),ot=K.el;q.appendChild(ot);const Tt=Ut(Y,J,l=>{J=l,O()},{ownerDocument:a.ownerDocument});q.appendChild(Tt);function Et(l){return L===l||l!==null&&!(E??[]).some(A=>A.key===l)?!1:(L=l,O(),!0)}const pt=a.ownerDocument.createElement("span");Object.assign(pt.style,{display:"inline-flex",gap:et.lg,alignItems:"center"}),q.appendChild(pt);const rt=Xt(pt,s.axisPrefs,l=>{s.onAxisPrefsChange?.(l),O()},a.ownerDocument),H=Vt(a);let it=null,L=null;function lt(l){const A=Q.find(B=>B.testNumber===l);return{unit:A?.unit,limitLow:A?.limitLow,limitHigh:A?.limitHigh}}function O(){const{includeLimits:l,clipOutliers:A}=rt.get();if(it?.disconnect(),it=null,y.innerHTML="",g===null){yt(y,"No parametric test data available for a histogram.");return}const B=E&&E.length>0?Rt(E,g,16,l?lt(g).limitLow:void 0,l?lt(g).limitHigh:void 0):null;if(Tt.style.display=B?"none":"",B){const{limitLow:n,limitHigh:i}=lt(g);if(rt.sync(l??!1,n!==void 0||i!==void 0),B.series.length===0){yt(y,"No parametric test data available for a histogram.");return}Bt(B);return}const ct=J!==null?[Y[J]]:Y,{unit:f,limitLow:w,limitHigh:D}=lt(g),C=Ot(ct,g),R=C.length?Math.min(...C):NaN,F=C.length?Math.max(...C):NaN,v=l??Qt(R,F,w,D);rt.sync(v,w!==void 0||D!==void 0);const z=A?Yt(C):null,G=z?{lo:Math.max(R,z.lo),hi:Math.min(F,z.hi)}:void 0;Z=G?C.filter(n=>n<G.lo||n>G.hi).length:0;const u=Kt(ct,g,16,v?w:void 0,v?D:void 0,G);if(u.length===0){yt(y,"No parametric test data available for a histogram.");return}const V=Math.max(...u.map(n=>n.count),1),j=a.ownerDocument.createElement("div");Object.assign(j.style,{fontSize:vt.body,color:wt.label,marginBottom:et.xxs}),j.textContent=`max ${V} dies/bucket`+(Z?` \xB7 ${Z} value${Z===1?"":"s"} outside clipped range`:""),y.appendChild(j);const k=a.ownerDocument.createElement("canvas");k.style.display="block",k.style.cursor="default",y.appendChild(k);let S=-1;const _=u[0].rangeLow,I=u[u.length-1].rangeHigh,xt=I-_||1,o=$t(Math.max(Math.abs(_),Math.abs(I)),f);function p(n){const i=at+36,r=k.clientWidth-i-at,e=n-Wt-gt;return{plotX:i,plotMaxWidth:Math.max(10,r),plotMaxHeight:e,plotTop:gt}}function M(n){const{plotX:i,plotMaxWidth:r}=p(Mt);if(n<i||n>i+r)return-1;const e=Math.floor((n-i)/r*u.length);return e>=0&&e<u.length?e:-1}function t(){Ct(k,j);const n=y.clientWidth,i=At(a,y,k,Mt),r=Pt(k,a,n,i);if(!r)return;const{ctx:e,theme:x}=r,{plotX:c,plotMaxWidth:b,plotMaxHeight:dt,plotTop:U}=p(i),$=U+dt,tt=b/u.length;Dt(e,c,U,$,b,V,{text:x.textMuted,axis:x.border,grid:x.border}),u.forEach((m,T)=>{const h=m.count/V*dt,Lt=c+T*tt,St=$-h;e.fillStyle=T===S?x.text:Ft,e.globalAlpha=T===S?1:.7,e.fillRect(Lt+1,St,Math.max(1,tt-2),h),e.globalAlpha=1,T===S&&(e.fillStyle=x.text,e.textAlign="center",e.textBaseline="bottom",e.fillText(`${m.count}`,Lt+tt/2,St-2))});const bt=m=>c+(m-_)/xt*b;e.font=`${X(-1)}px system-ui, sans-serif`;const{offAxis:d}=Jt({dataMin:_,dataMax:I,limitLow:w,limitHigh:D,includeLimits:!1});qt(e,d,{left:c,right:c+b,top:U,bottom:$},"horizontal",x.limitLine,o.tick);const P=new Set(d.map(m=>m.value));for(const[m,T]of[[w,"LSL"],[D,"USL"]]){if(m===void 0||P.has(m))continue;const h=bt(m);e.strokeStyle=x.limitLine,e.setLineDash([3,3]),e.lineWidth=1,e.beginPath(),e.moveTo(h,U),e.lineTo(h,$),e.stroke(),e.setLineDash([]),e.fillStyle=x.limitLine,e.textAlign="center",e.textBaseline="bottom",e.fillText(T,h,U-2)}e.font=`${X(-1)}px system-ui, sans-serif`,e.strokeStyle=x.border,e.lineWidth=1,e.beginPath(),e.moveTo(c,$),e.lineTo(c+b,$),e.stroke(),e.fillStyle=x.textMuted,e.textAlign="center",e.textBaseline="top";const mt=Math.max(2,Math.floor(b/44)),ut=Math.ceil(u.length/mt),st=Math.max(1,ut);for(let m=0;m<=u.length;m+=st){const T=m<u.length?u[m].rangeLow:u[u.length-1].rangeHigh,h=c+m*tt;e.beginPath(),e.moveTo(h,$),e.lineTo(h,$+4),e.stroke(),e.fillText(o.tick(T),h,$+6)}o.unitLabel&&(e.save(),e.font=`${X(-1)}px system-ui, sans-serif`,e.fillStyle=x.textMuted,e.textAlign="right",e.textBaseline="top",e.fillText(`(${o.unitLabel})`,c+b,$+20),e.restore())}k.addEventListener("mousemove",n=>{const i=k.getBoundingClientRect(),r=M(n.clientX-i.left);if(r!==S&&(S=r,t()),r>=0){const e=u[r];H.innerHTML=`<strong>${ht(e.rangeLow,f,"engineering")} \u2013 ${ht(e.rangeHigh,f,"engineering")}</strong><br>${e.count} dies`,H.style.display="block",Ht(H,a,n.clientX,n.clientY)}else H.style.display="none"}),k.addEventListener("mouseleave",()=>{S!==-1&&(S=-1,t()),H.style.display="none"}),it=kt(a,()=>t()),t()}function Bt(l){const{unit:A,limitLow:B,limitHigh:ct}=lt(g),f=l.ranges,w=l.series,D=zt;L&&!w.some(o=>o.groupKey===L)&&(L=null);const C=Math.max(1,...w.flatMap(o=>o.counts)),R=a.ownerDocument.createElement("div");Object.assign(R.style,{fontSize:vt.body,color:wt.label,marginBottom:et.xxs}),R.textContent=`${w.length} groups \xB7 max ${C} dies/bucket`,y.appendChild(R);const F=a.ownerDocument.createElement("div");Object.assign(F.style,{display:"flex",flexWrap:"wrap",gap:et.sm,marginTop:et.md,marginBottom:et.xl}),w.forEach((o,p)=>{const M=te(o.groupKey,D(p),a.ownerDocument),t=M.el;jt(t,a,H,`${o.groupKey} \u2014 click to emphasize (dim the rest)`),M.setState({selected:L===o.groupKey,dimmed:L!==null&&L!==o.groupKey}),t.addEventListener("click",()=>{const n=L===o.groupKey?null:o.groupKey;Et(n),s.onGroupChange?.(n)}),F.appendChild(t)}),y.appendChild(F);const v=a.ownerDocument.createElement("canvas");v.style.display="block",v.style.cursor="default",y.appendChild(v);const z=f[0].rangeLow,G=f[f.length-1].rangeHigh,u=G-z||1,V=$t(Math.max(Math.abs(z),Math.abs(G)),A),j=o=>{const p=(o.ownerDocument.defaultView??window).getComputedStyle(o);return o.offsetHeight+(parseFloat(p.marginTop)||0)+(parseFloat(p.marginBottom)||0)},k=()=>j(R)+j(F);let S=-1,_={plotX:at+36,plotMaxWidth:10,barWidth:10};function I(){Ct(v,k());const o=y.clientWidth,p=At(a,y,v,Mt),M=Pt(v,a,o,p);if(!M)return;const{ctx:t,theme:n}=M,i=at+36,r=Math.max(10,o-i-at),e=gt,x=p-Wt-gt,c=e+x,b=r/f.length;_={plotX:i,plotMaxWidth:r,barWidth:b},Dt(t,i,e,c,r,C,{text:n.textMuted,axis:n.border,grid:n.border}),S>=0&&(t.fillStyle=n.bgHover,t.globalAlpha=.5,t.fillRect(i+S*b,e,b,x),t.globalAlpha=1);const dt=w.map((d,P)=>({s:d,i:P})).sort((d,P)=>(d.s.groupKey===L?1:0)-(P.s.groupKey===L?1:0));for(const{s:d,i:P}of dt){const W=L===d.groupKey,mt=L!==null&&!W,ut=D(P);t.strokeStyle=ut,t.fillStyle=ut,t.lineWidth=W?2:1,t.globalAlpha=mt?.12:L?.32:.22,t.beginPath(),t.moveTo(i,c),d.counts.forEach((st,m)=>{const T=i+m*b,h=c-st/C*x;t.lineTo(T,h),t.lineTo(T+b,h)}),t.lineTo(i+r,c),t.closePath(),t.fill(),t.globalAlpha=mt?.3:1,t.beginPath(),d.counts.forEach((st,m)=>{const T=i+m*b,h=c-st/C*x;m===0?t.moveTo(T,h):t.lineTo(T,h),t.lineTo(T+b,h)}),t.stroke()}t.globalAlpha=1;const U=d=>i+(d-z)/u*r;t.font=`${X(-1)}px system-ui, sans-serif`;for(const[d,P]of[[B,"LSL"],[ct,"USL"]]){if(d===void 0)continue;const W=U(d);t.strokeStyle=n.limitLine,t.setLineDash([3,3]),t.lineWidth=1,t.beginPath(),t.moveTo(W,e),t.lineTo(W,c),t.stroke(),t.setLineDash([]),t.fillStyle=n.limitLine,t.textAlign="center",t.textBaseline="bottom",t.fillText(P,W,e-2)}t.font=`${X(-1)}px system-ui, sans-serif`,t.strokeStyle=n.border,t.lineWidth=1,t.beginPath(),t.moveTo(i,c),t.lineTo(i+r,c),t.stroke(),t.fillStyle=n.textMuted,t.textAlign="center",t.textBaseline="top";const tt=Math.max(2,Math.floor(r/44)),bt=Math.max(1,Math.ceil(f.length/tt));for(let d=0;d<=f.length;d+=bt){const P=d<f.length?f[d].rangeLow:f[f.length-1].rangeHigh,W=i+d*b;t.beginPath(),t.moveTo(W,c),t.lineTo(W,c+4),t.stroke(),t.fillText(V.tick(P),W,c+6)}V.unitLabel&&(t.save(),t.font=`${X(-1)}px system-ui, sans-serif`,t.fillStyle=n.textMuted,t.textAlign="right",t.textBaseline="top",t.fillText(`(${V.unitLabel})`,i+r,c+20),t.restore())}function xt(o){const{plotX:p,plotMaxWidth:M,barWidth:t}=_;if(o<p||o>p+M)return-1;const n=Math.floor((o-p)/t);return n>=0&&n<f.length?n:-1}v.addEventListener("mousemove",o=>{const p=v.getBoundingClientRect(),M=xt(o.clientX-p.left);if(M!==S&&(S=M,I()),M>=0){const t=f[M],n=w.map((i,r)=>`<span style="${Zt(D(r))};margin-right:4px"></span>${i.groupKey}: ${i.counts[M]}`).join("<br>");H.innerHTML=`<strong>${ht(t.rangeLow,A,"engineering")} \u2013 ${ht(t.rangeHigh,A,"engineering")}</strong><br>${n}`,H.style.display="block",Ht(H,a,o.clientX,o.clientY)}else H.style.display="none"}),v.addEventListener("mouseleave",()=>{S!==-1&&(S=-1,I()),H.style.display="none"}),it=kt(a,()=>I()),I()}O();function It(l){K.set(l)&&(g=l,O())}function Nt(l){rt.set(l)&&O()}return{card:a,setTest:It,setAxisPrefs:Nt,destroy:()=>it?.disconnect()}}
1
+ import{buildTestHistogramData as jt,collectTestValues as Ht,buildTestHistogramSeries as _t,testValueExtent as At}from"../../stats/histogram.js";import{SPACE as at,fontPx as Q,FONT as Ct,CLR as $t}from"../toolbar.js";import{fmt as bt}from"../../renderer/fmt.js";import{QUANTITY as Ut,categorical as Xt}from"./palette.js";import{cardShell as Yt,observeResize as Pt,makeTooltip as Qt,attachChartTip as qt,positionChartTooltip as Wt,makeLinkedTestSelect as Jt,makeWaferSelect as Zt,makeLinkedAxisPrefs as te,renderEmptyState as Tt,chartFillHeight as Dt,applyCanvasFlow as Et,makeAxisFormat as It,PADDING as ft,robustFence as Bt,shouldIncludeLimitsByDefault as Ft,drawOffAxisLimits as ee,resolveAxisRange as ne,chartSwatchCss as ie,makeSeriesLegendItem as oe,prepareCanvas as Kt}from"./chartShell.js";const St=230,Ot=36,yt=18;function Rt(l,G,q,J,Mt,B,r,y=4){const Z=Math.max(1,B/y),rt=Math.pow(10,Math.floor(Math.log10(Z))),d=Z/rt,tt=(d<=1?1:d<=2?2:d<=5?5:10)*rt,K=J-q;l.save(),l.font=`${Q(-1)}px system-ui, sans-serif`,l.textAlign="right",l.textBaseline="middle";for(let V=0;V<=B+1e-9;V+=tt){const ct=J-V/(B||1)*K;l.strokeStyle=r.grid,l.lineWidth=1,l.beginPath(),l.moveTo(G,ct),l.lineTo(G+Mt,ct),l.stroke(),l.fillStyle=r.text,l.fillText(String(Math.round(V)),G-4,ct)}l.strokeStyle=r.axis,l.lineWidth=1,l.beginPath(),l.moveTo(G,q),l.lineTo(G,J),l.stroke(),l.restore()}export function renderHistogramPanel(l){const{title:G="Test value distribution",items:q,testDefs:J,onSaveImage:Mt,groups:B}=l,{card:r,body:y,controlsRow:Z}=Yt(G,Mt,l.ownerDocument),rt=J.filter(s=>s.testNumber!==void 0);let d=l.selectedTestNumber??rt[0]?.testNumber??null,tt=null,K=0;const V=Jt(rt,d,s=>{d=s,j(),l.onTestChange?.(s)},{maxWidth:"200px",ownerDocument:r.ownerDocument}),ct=V.el;Z.appendChild(ct);const wt=Zt(q,tt,s=>{tt=s,j()},{ownerDocument:r.ownerDocument});Z.appendChild(wt);function Nt(s){return S===s||s!==null&&!(B??[]).some(v=>v.key===s)?!1:(S=s,j(),!0)}const Lt=r.ownerDocument.createElement("span");Object.assign(Lt.style,{display:"inline-flex",gap:at.lg,alignItems:"center"}),Z.appendChild(Lt);const gt=te(Lt,l.axisPrefs,s=>{l.onAxisPrefsChange?.(s),j()},r.ownerDocument),C=Qt(r);let dt=null,S=null;function et(s){const v=J.find(nt=>nt.testNumber===s);return{unit:v?.unit,limitLow:v?.limitLow,limitHigh:v?.limitHigh}}function j(){const{includeLimits:s,clipOutliers:v}=gt.get();if(dt?.disconnect(),dt=null,y.innerHTML="",d===null){Tt(y,"No parametric test data available for a histogram.");return}const nt=!!B&&B.length>0;let it=!1,u;if(nt){const{limitLow:a,limitHigh:m}=et(d),o=B.flatMap(x=>x.items),{min:e,max:p}=At(o,d);if(it=s??Ft(e,p,a,m),v){const x=Ht(o,d),H=Bt(x);H&&(u={lo:Math.max(e,H.lo),hi:Math.min(p,H.hi)},K=x.reduce((lt,E)=>lt+(E<u.lo||E>u.hi?1:0),0))}u||(K=0)}const $=nt?_t(B,d,16,it?et(d).limitLow:void 0,it?et(d).limitHigh:void 0,u):null;if(wt.style.display=$?"none":"",$){const{limitLow:a,limitHigh:m}=et(d);if(gt.sync(it,a!==void 0||m!==void 0),$.series.length===0){Tt(y,"No parametric test data available for a histogram.");return}zt($);return}const _=tt!==null?[q[tt]]:q,{unit:O,limitLow:P,limitHigh:W}=et(d),k=Ht(_,d),{min:ot,max:mt}=At(_,d),ut=s??Ft(ot,mt,P,W);gt.sync(ut,P!==void 0||W!==void 0);const U=v?Bt(k):null,X=U?{lo:Math.max(ot,U.lo),hi:Math.min(mt,U.hi)}:void 0;K=X?k.filter(a=>a<X.lo||a>X.hi).length:0;const h=jt(_,d,16,ut?P:void 0,ut?W:void 0,X);if(h.length===0){Tt(y,"No parametric test data available for a histogram.");return}const D=Math.max(...h.map(a=>a.count),1),Y=r.ownerDocument.createElement("div");Object.assign(Y.style,{fontSize:Ct.body,color:$t.label,marginBottom:at.xxs}),Y.textContent=`max ${D} dies/bucket`+(K?` \xB7 ${K} value${K===1?"":"s"} outside clipped range`:""),y.appendChild(Y);const M=r.ownerDocument.createElement("canvas");M.style.display="block",M.style.cursor="default",y.appendChild(M);let F=-1;const n=h[0].rangeLow,g=h[h.length-1].rangeHigh,L=g-n||1,t=It(Math.max(Math.abs(n),Math.abs(g)),O);function c(a){const m=ft+36,o=M.clientWidth-m-ft,e=a-Ot-yt;return{plotX:m,plotMaxWidth:Math.max(10,o),plotMaxHeight:e,plotTop:yt}}function f(a){const{plotX:m,plotMaxWidth:o}=c(St);if(a<m||a>m+o)return-1;const e=Math.floor((a-m)/o*h.length);return e>=0&&e<h.length?e:-1}function T(){Et(M,Y);const a=y.clientWidth,m=Dt(r,y,M,St),o=Kt(M,r,a,m);if(!o)return;const{ctx:e,theme:p}=o,{plotX:x,plotMaxWidth:H,plotMaxHeight:lt,plotTop:E}=c(m),i=E+lt,w=H/h.length;Rt(e,x,E,i,H,D,{text:p.textMuted,axis:p.border,grid:p.border}),h.forEach((b,z)=>{const I=b.count/D*lt,vt=x+z*w,kt=i-I;e.fillStyle=z===F?p.text:Ut,e.globalAlpha=z===F?1:.7,e.fillRect(vt+1,kt,Math.max(1,w-2),I),e.globalAlpha=1,z===F&&(e.fillStyle=p.text,e.textAlign="center",e.textBaseline="bottom",e.fillText(`${b.count}`,vt+w/2,kt-2))});const A=b=>x+(b-n)/L*H;e.font=`${Q(-1)}px system-ui, sans-serif`;const{offAxis:ht}=ne({dataMin:n,dataMax:g,limitLow:P,limitHigh:W,includeLimits:!1});ee(e,ht,{left:x,right:x+H,top:E,bottom:i},"horizontal",p.limitLine,t.tick);const pt=new Set(ht.map(b=>b.value));for(const[b,z]of[[P,"LSL"],[W,"USL"]]){if(b===void 0||pt.has(b))continue;const I=A(b);e.strokeStyle=p.limitLine,e.setLineDash([3,3]),e.lineWidth=1,e.beginPath(),e.moveTo(I,E),e.lineTo(I,i),e.stroke(),e.setLineDash([]),e.fillStyle=p.limitLine,e.textAlign="center",e.textBaseline="bottom",e.fillText(z,I,E-2)}e.font=`${Q(-1)}px system-ui, sans-serif`,e.strokeStyle=p.border,e.lineWidth=1,e.beginPath(),e.moveTo(x,i),e.lineTo(x+H,i),e.stroke(),e.fillStyle=p.textMuted,e.textAlign="center",e.textBaseline="top";const st=Math.max(2,Math.floor(H/44)),R=Math.ceil(h.length/st),N=Math.max(1,R);for(let b=0;b<=h.length;b+=N){const z=b<h.length?h[b].rangeLow:h[h.length-1].rangeHigh,I=x+b*w;e.beginPath(),e.moveTo(I,i),e.lineTo(I,i+4),e.stroke(),e.fillText(t.tick(z),I,i+6)}t.unitLabel&&(e.save(),e.font=`${Q(-1)}px system-ui, sans-serif`,e.fillStyle=p.textMuted,e.textAlign="right",e.textBaseline="top",e.fillText(`(${t.unitLabel})`,x+H,i+20),e.restore())}M.addEventListener("mousemove",a=>{const m=M.getBoundingClientRect(),o=f(a.clientX-m.left);if(o!==F&&(F=o,T()),o>=0){const e=h[o];C.innerHTML=`<strong>${bt(e.rangeLow,O,"engineering")} \u2013 ${bt(e.rangeHigh,O,"engineering")}</strong><br>${e.count} dies`,C.style.display="block",Wt(C,r,a.clientX,a.clientY)}else C.style.display="none"}),M.addEventListener("mouseleave",()=>{F!==-1&&(F=-1,T()),C.style.display="none"}),dt=Pt(r,()=>T()),T()}function zt(s){const{unit:v,limitLow:nt,limitHigh:it}=et(d),u=s.ranges,$=s.series,_=Xt;S&&!$.some(n=>n.groupKey===S)&&(S=null);const O=Math.max(1,...$.flatMap(n=>n.counts)),P=r.ownerDocument.createElement("div");Object.assign(P.style,{fontSize:Ct.body,color:$t.label,marginBottom:at.xxs}),P.textContent=`${$.length} groups \xB7 max ${O} dies/bucket`,y.appendChild(P);const W=r.ownerDocument.createElement("div");Object.assign(W.style,{display:"flex",flexWrap:"wrap",gap:at.sm,marginTop:at.md,marginBottom:at.xl}),$.forEach((n,g)=>{const L=oe(n.groupKey,_(g),r.ownerDocument),t=L.el;qt(t,r,C,`${n.groupKey} \u2014 click to emphasize (dim the rest)`),L.setState({selected:S===n.groupKey,dimmed:S!==null&&S!==n.groupKey}),t.addEventListener("click",()=>{const c=S===n.groupKey?null:n.groupKey;Nt(c),l.onGroupChange?.(c)}),W.appendChild(t)}),y.appendChild(W);const k=r.ownerDocument.createElement("canvas");k.style.display="block",k.style.cursor="default",y.appendChild(k);const ot=u[0].rangeLow,mt=u[u.length-1].rangeHigh,ut=mt-ot||1,U=It(Math.max(Math.abs(ot),Math.abs(mt)),v),X=n=>{const g=(n.ownerDocument.defaultView??window).getComputedStyle(n);return n.offsetHeight+(parseFloat(g.marginTop)||0)+(parseFloat(g.marginBottom)||0)},h=()=>X(P)+X(W);let D=-1,Y={plotX:ft+36,plotMaxWidth:10,barWidth:10};function M(){Et(k,h());const n=y.clientWidth,g=Dt(r,y,k,St),L=Kt(k,r,n,g);if(!L)return;const{ctx:t,theme:c}=L,f=ft+36,T=Math.max(10,n-f-ft),a=yt,m=g-Ot-yt,o=a+m,e=T/u.length;Y={plotX:f,plotMaxWidth:T,barWidth:e},Rt(t,f,a,o,T,O,{text:c.textMuted,axis:c.border,grid:c.border}),D>=0&&(t.fillStyle=c.bgHover,t.globalAlpha=.5,t.fillRect(f+D*e,a,e,m),t.globalAlpha=1);const p=$.map((i,w)=>({s:i,i:w})).sort((i,w)=>(i.s.groupKey===S?1:0)-(w.s.groupKey===S?1:0));for(const{s:i,i:w}of p){const A=S===i.groupKey,ht=S!==null&&!A,pt=_(w);t.strokeStyle=pt,t.fillStyle=pt,t.lineWidth=A?2:1,t.globalAlpha=ht?.12:S?.32:.22,t.beginPath(),t.moveTo(f,o),i.counts.forEach((xt,st)=>{const R=f+st*e,N=o-xt/O*m;t.lineTo(R,N),t.lineTo(R+e,N)}),t.lineTo(f+T,o),t.closePath(),t.fill(),t.globalAlpha=ht?.3:1,t.beginPath(),i.counts.forEach((xt,st)=>{const R=f+st*e,N=o-xt/O*m;st===0?t.moveTo(R,N):t.lineTo(R,N),t.lineTo(R+e,N)}),t.stroke()}t.globalAlpha=1;const x=i=>f+(i-ot)/ut*T;t.font=`${Q(-1)}px system-ui, sans-serif`;for(const[i,w]of[[nt,"LSL"],[it,"USL"]]){if(i===void 0)continue;const A=x(i);t.strokeStyle=c.limitLine,t.setLineDash([3,3]),t.lineWidth=1,t.beginPath(),t.moveTo(A,a),t.lineTo(A,o),t.stroke(),t.setLineDash([]),t.fillStyle=c.limitLine,t.textAlign="center",t.textBaseline="bottom",t.fillText(w,A,a-2)}t.font=`${Q(-1)}px system-ui, sans-serif`,t.strokeStyle=c.border,t.lineWidth=1,t.beginPath(),t.moveTo(f,o),t.lineTo(f+T,o),t.stroke(),t.fillStyle=c.textMuted,t.textAlign="center",t.textBaseline="top";const lt=Math.max(2,Math.floor(T/44)),E=Math.max(1,Math.ceil(u.length/lt));for(let i=0;i<=u.length;i+=E){const w=i<u.length?u[i].rangeLow:u[u.length-1].rangeHigh,A=f+i*e;t.beginPath(),t.moveTo(A,o),t.lineTo(A,o+4),t.stroke(),t.fillText(U.tick(w),A,o+6)}U.unitLabel&&(t.save(),t.font=`${Q(-1)}px system-ui, sans-serif`,t.fillStyle=c.textMuted,t.textAlign="right",t.textBaseline="top",t.fillText(`(${U.unitLabel})`,f+T,o+20),t.restore())}function F(n){const{plotX:g,plotMaxWidth:L,barWidth:t}=Y;if(n<g||n>g+L)return-1;const c=Math.floor((n-g)/t);return c>=0&&c<u.length?c:-1}k.addEventListener("mousemove",n=>{const g=k.getBoundingClientRect(),L=F(n.clientX-g.left);if(L!==D&&(D=L,M()),L>=0){const t=u[L],c=$.map((f,T)=>`<span style="${ie(_(T))};margin-right:4px"></span>${f.groupKey}: ${f.counts[L]}`).join("<br>");C.innerHTML=`<strong>${bt(t.rangeLow,v,"engineering")} \u2013 ${bt(t.rangeHigh,v,"engineering")}</strong><br>${c}`,C.style.display="block",Wt(C,r,n.clientX,n.clientY)}else C.style.display="none"}),k.addEventListener("mouseleave",()=>{D!==-1&&(D=-1,M()),C.style.display="none"}),dt=Pt(r,()=>M()),M()}j();function Gt(s){V.set(s)&&(d=s,j())}function Vt(s){gt.set(s)&&j()}return{card:r,setTest:Gt,setAxisPrefs:Vt,destroy:()=>dt?.disconnect()}}
@@ -6,7 +6,6 @@ export interface RegionYieldDiagramOptions {
6
6
  mode: RegionYieldMode;
7
7
  /** Ring mode: ordered ring 1 (core) → ring N (edge), matching `buildRegionYieldData`'s output for `buildRingRegions`. Quadrant mode: any order — each row's quadrant is read from its `key` (`quadrant:NE` etc.), not position. */
8
8
  rows: RegionYieldDatum[];
9
- colorScheme?: string;
10
9
  onSaveImage?: SaveImageHandler;
11
10
  /** Document to build this panel's DOM into. Default `document` — pass the
12
11
  * host's own `ownerDocument` when the container might live in a
@@ -11,7 +11,12 @@ export interface ScatterPanelOptions {
11
11
  testDefs: TestDef[];
12
12
  xTestNumber?: number;
13
13
  yTestNumber?: number;
14
- colorScheme?: string;
14
+ /**
15
+ * The host map's resolved hard-bin colours (`View.binColors.hard`), so a
16
+ * point is the colour its die is on the map. Omitted ⇒ points without a
17
+ * group colour take the no-data fill.
18
+ */
19
+ binColors?: ReadonlyMap<number, string>;
15
20
  onSaveImage?: SaveImageHandler;
16
21
  /**
17
22
  * When the Analysis tab's "Group by" is active, every group's points are