@wafertools/wafermap 0.27.0 → 0.28.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 +310 -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 +5 -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 +38 -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 +42 -9
  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 +67 -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 +45 -26
  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,316 @@ under `### Breaking`.
22
22
 
23
23
  ---
24
24
 
25
+ ## [0.28.0] — 2026-09-11
26
+
27
+ ### Breaking
28
+
29
+ - **Bin and value colours are separate preferences; `colorScheme` is removed.**
30
+ `WaferViewOptions.colorScheme` / `ViewOptions.colorScheme` → `binColorScheme` (Hard/Soft Bin maps)
31
+ and `valueColorScheme` (Test Value and the three stacked modes — a stacked-bin map is a value map,
32
+ each position's occurrence rate). The single option was reset to `'default'` on every switch into a
33
+ bin mode (the "not bin-compatible" reset, written out three times), so a value-map choice never
34
+ survived a mode switch and no host could persist one honestly. `View.colorScheme` →
35
+ `View.binColorScheme` + `View.valueColorScheme`. Both are `WaferPreferences`, reported through
36
+ `onViewOptionsChange` with category `'preference'`.
37
+ - **`registerColorScheme` / `getColorScheme` / `listColorSchemes` and `ColorScheme` are replaced by
38
+ two registries:** `registerBinColorScheme` / `getBinColorScheme` / `listBinColorSchemes`
39
+ (`BinColorScheme = { label, pass, fail }`) and `registerValueColorScheme` / `getValueColorScheme` /
40
+ `listValueColorSchemes` (`ValueColorScheme = { label, forValue }`). A host-registered bin palette
41
+ now appears in the bin-mode Palette menu — the old menu filtered bin modes by hardcoded name, so it
42
+ never could. `registerBinColorScheme` throws on an empty `pass` or `fail` list.
43
+ - **`hardBinColor`, `softBinColor`, `hardBinGreyscale` and `HARD_BIN_GREY` are removed.** They hashed
44
+ the bin number (see Fixed). Use `resolveBinColors`, or `View.binColors` from a rendered map.
45
+ - **The `'custom'` pseudo-scheme is gone.** `BinDef.color` is now a layer every bin palette honours,
46
+ on by default; `useDefinedBinColors: false` (Palette menu: *Use colours from bin definitions*) turns
47
+ it off. Definition colours used to apply only while "Custom" was selected, and the gallery switched
48
+ to it only when the host had not passed a scheme — so a host restoring a saved scheme silently
49
+ hid the colours that came with the data.
50
+ - **Value gradient names:** `'accessible'` → `'cividis'` (the bin half of the old scheme is now the
51
+ `'accessible'` bin palette, labelled *Colour-blind safe*). `'thermal'` is removed: it had the same
52
+ keypoints as `'default'` — two menu rows drawing identical maps.
53
+ - **Bin maps look different.** Both bin palettes were re-selected by measurement and colours are now
54
+ assigned by pass/fail and die count rather than by bin number (see Fixed), so images of bin maps
55
+ will not match earlier ones.
56
+ - Insights internals: `InsightsTabDeps.getColorSchemeName` → `getBinColors`;
57
+ `ScatterPanelOptions.colorScheme` → `binColors`; the unused `colorScheme` field is removed from the
58
+ bin-cluster, boxplot, capability, correlation, histogram and region-yield panel options.
59
+
60
+ ### Added
61
+
62
+ - **`resolveBinColors(dies, options)`** — the one rule for bin colour, used by the map, its legends,
63
+ the summary panels, the mapless footer and the Insights charts. Pass bins (per `passBins`) take the
64
+ palette's pass colours, failing bins its fail colours, each ranked by die count; a soft bin passes
65
+ when every die carrying it passes. Returns `BinColors` (`{ hard, soft, shared }`). Exposed on every
66
+ view as **`View.binColors`**; **`ViewOptions.binColors`** lets a host share one assignment across
67
+ several maps (`renderWaferGallery` does this for every wafer it shows, so a bin is one colour on every card).
68
+ - **`diePassStatus(die, passBins)`** (`core`) — the per-die pass rule (hard bin, else soft bin),
69
+ shared by yield, the failing-die hatch and bin colouring.
70
+ - **`bin-colors-shared` warning** — raised by the renderers when bins on the bin map on screen share
71
+ a colour (more bins than the palette has colours, or a `BinDef.color` repeating one). A gallery
72
+ states it once for all its wafers rather than per card.
73
+ - `tests/binPalettes.test.mjs` re-measures the built-in bin palettes (CIEDE2000, with simulated
74
+ deuteranopia, protanopia and tritanopia) so an edit cannot quietly erode their separation.
75
+ - `docs/api.md` now documents `standardDiameters` (§4.1.12) and the metadata helpers
76
+ `metadataDisplayValue` / `metadataCategoricalValue` / `discoverDieMetadataKeys` (§10.1). All
77
+ four were public exports that the reference never mentioned — `STANDARD_WAFER_DIAMETERS_MM`
78
+ since 0.27.0, where it shipped as the documented escape hatch for the new
79
+ `non-standard-diameter` advisory and was therefore undiscoverable outside the source; the
80
+ other three since 0.24.0.
81
+ - `scripts/check-api-claims.mjs` now also fails when a public export (per
82
+ `tests/export-surface.test.mjs`'s snapshot, the deliberate record of what is public) is never
83
+ mentioned in `docs/api.md`. The bar is low on purpose — the name appearing anywhere, even in a
84
+ code block, is enough — because this catches exports forgotten entirely, which is the failure
85
+ that actually happened, not how well each one is explained.
86
+ - `tests/insightsScopeReconciliation.test.mjs` pins scope-aware test-definition reconciliation in
87
+ **all three** Insights views. The behaviour was already correct; nothing tested it, and the
88
+ whole-load version shipped once before.
89
+ - **A boxplot leaf click now does something in a single-wafer render.** `renderWaferMap`'s
90
+ Insights tab passes no `openWafer` — the only wafer there is to open is the one already on
91
+ screen — which left the "Test value distribution" row inert, silently throwing away the other
92
+ half of the click: the selected test. New `InsightsTabDeps.focusTest`, wired by
93
+ `renderWaferMap`, switches the map behind the tab into test-value mode on the clicked test
94
+ and closes Insights. The gallery is unchanged: where `openWafer` exists it wins, since
95
+ opening the wafer already lands it on that test.
96
+ - `BoxplotPanelOptions.openActionLabel` (optional) supplies the wording for both click
97
+ affordances, so the chart says what it will actually do — "click a box to show this test on
98
+ the map" instead of "…to open that wafer". One string, not two, because the hint and the
99
+ tooltip describe one action.
100
+ - `focusTest` is consulted only for a leaf row of the sole item: with more than one wafer on
101
+ screen, "show this test on the map" would show it for a different wafer than the one
102
+ clicked. The trend panel is deliberately not wired to it — it renders an empty state below
103
+ two wafers, so that branch could never fire.
104
+ - Reported against tsmap, where a single-wafer load has exactly this gap; logged there as
105
+ WMAP_ISSUES.md #51.
106
+
107
+ - `scripts/check-doc-links.mjs` (new, wired into `npm run check`) resolves every internal
108
+ documentation link and fails on a dead one. Zensical has no link validation and no redirect
109
+ map, so a wrong anchor is not an error — the browser lands at the top of the page and the
110
+ reader concludes the docs are wrong about themselves, with nothing in the build saying a word.
111
+ It checks that link targets exist, that every `#anchor` names a heading the Markdown really
112
+ produces (using Python-Markdown's slug algorithm, the one Zensical actually runs — validated
113
+ against a real build at 385/385 anchors), that no two headings in a page slugify to the same
114
+ id, and that every `zensical.toml` nav entry resolves. The hand-written example and demo pages
115
+ are scanned too, which is how `theming.html`'s `../api.html#...` was found: the site serves
116
+ that page at `api/`, so the link had never worked. tsmap carries the same script.
117
+
118
+ ### Changed
119
+
120
+ - **"Mark failing dies" moved from Colour scheme to Overlays, and both menus gained an action
121
+ to undo themselves.** It was the one entry in Colour scheme that was not a scheme — a marker
122
+ drawn over dies, sitting among mutually exclusive palettes — while its sibling concept, the
123
+ Spec/Test pass-fail display, was already in Overlays. The two are the same judgement about the
124
+ same dies, and splitting them across two menus made neither findable from the other. Overlays
125
+ was already the right home in a second way: it already carries conditionally-disabled rows
126
+ (Reticle grid), so a bin-modes-only entry is not out of place there.
127
+ **Overlays gained "Clear overlays"** — six toggles are tedious to turn off one at a time, and
128
+ the row is greyed when nothing is on, so the menu now answers "is anything active?" without the
129
+ reader auditing every line.
130
+ **Orientation gained "Reset orientation"**, which matters more than convenience: rotation and
131
+ mirroring do not commute (`mirror ∘ rot(θ) = rot(−θ) ∘ mirror`), so a reader who has rotated
132
+ and flipped a few times cannot reliably click their way back. Reset is exactly right, and a
133
+ wafer map read in the wrong orientation is the class of mistake this library exists to prevent.
134
+ Both are greyed at the default state.
135
+ `CheckMenuRow` gained `action: true` for rows that *do* something rather than holding a state.
136
+ The first version of this change shipped them as ordinary rows, which rendered an unchecked ✓
137
+ and announced them to a screen reader as checkboxes that were off. The surface snapshot below
138
+ caught it on its first real use — the diff showed `Clear overlays [unchecked, disabled]`.
139
+ - **`scripts/ui-surface.mjs` (new) records the whole interactive surface to a committed text
140
+ file** — `docs/ui-surface.txt`, 117 controls across the single map, the gallery and the Insights
141
+ tab, with every label, accessible name, hint, checked/disabled state and menu ordering.
142
+ `npm run ui:surface` regenerates it; `npm run ui:surface:check` fails on un-recorded drift.
143
+ This library's chrome ships inside other people's applications, and every existing check asks
144
+ whether it is built correctly rather than what it says. Reading the whole surface as one page
145
+ is also what makes duplication and odd ordering visible — those are invisible while each menu
146
+ is only ever seen alone. Not in `npm run check`: it needs a browser and a built `dist/`, like
147
+ `screenshots`.
148
+ - **`WMAP_VERSION` and `WMAP_BUILD_TIME` are now public**, from `@wafertools/wafermap/render`.
149
+ They already existed — generated by `sync-version.mjs` on every build — but only reached an
150
+ internal `console.log`. A host embedding this library could tell a user which *application*
151
+ they were running and had no way to say which **engine** was underneath, and "check the browser
152
+ console" is not an answer you can give a fab engineer asking why a map looks wrong. Being
153
+ generated at build time, they describe the bundle actually loaded rather than whatever a nearby
154
+ `package.json` claims — different things whenever a host is linked to a local checkout. tsmap
155
+ shows both in its About dialog from 0.1.34.
156
+ - **The docs site gained a light/dark theme, and most of Markdown.** Zensical is bumped
157
+ 0.0.51 → 0.0.60 (nine releases; `requirements.txt`, so CI and local builds move together),
158
+ and the config now uses what it offers.
159
+ The find that prompted it: **declaring any `[project.markdown_extensions.*]` section replaces
160
+ Zensical's default set wholesale rather than merging into it.** The config set only `toc` and
161
+ `pymdownx.highlight`, which had been silently switching off `admonition`, `abbr`, `def_list`,
162
+ `attr_list`, `tasklist`, `mark`, `caret` and `tilde` for the life of the site. Nothing
163
+ reported it, because unsupported syntax renders as ordinary paragraph text — it reads as "we
164
+ don't use admonitions here", not as a fault. Verified by deleting the sections, at which point
165
+ `!!! note` and `*[ABBR]:` both started working. The full set is now listed explicitly, with
166
+ that trap written down beside it. (Mermaid was never affected: Zensical renders those fences
167
+ natively.)
168
+ New: a **light/dark/system palette toggle in the header** — the site was light-only, which sat
169
+ oddly beside a library whose entire chrome is themeable and whose own guide had a dark-theme
170
+ bug fixed in 0.26.1. `docs/stylesheets/extra.css` stopped hardcoding three light greys and now
171
+ bridges Material's palette onto wmap's `--wmap-*` custom properties, so **the live wafer-map
172
+ demos follow the theme too** rather than sitting as bright panels on a dark page — the same
173
+ trick tsmap uses for its own 16 themes.
174
+ Also enabled: `toc.follow` and `navigation.top` (a 4,150-line API reference is the case they
175
+ exist for), `navigation.tracking` so a link copied from deep in a page points there,
176
+ `navigation.footer`, `search.highlight`/`search.share`, `content.code.annotate`/`select`,
177
+ `content.tabs.link`, and `content.tooltips` fed by a shared `includes/abbreviations.md` —
178
+ domain terms only (STDF, PTR, Cpk, the bin records), since tooltipping every "API", "UI" and
179
+ "CSV" underlined most sentences and told the reader nothing. The include lives outside `docs/`
180
+ because a snippet inside it is also built as a page of its own.
181
+ Checked rather than assumed: heading ids are byte-identical across the bump, all 670 site
182
+ anchors still resolve, no CLI flag was mangled by the newly-enabled `smartsymbols` (`--tests`
183
+ and friends survive intact), and the dark palette was driven in a real browser.
184
+ - **The Quick Start's synthetic-lot generator now has one home.** The same ~20 lines existed
185
+ four times — `docs/quickstart.md`'s copy-paste example, `docs/examples/quickstart-live.html`,
186
+ and a comment-stripped copy inlined in `scripts/capture-definitions.mjs` to shoot
187
+ `quickstart-first-map.png` — all byte-identical, with nothing enforcing it. Editing the doc
188
+ would have left the "open this in your browser" page and the screenshot directly beneath it
189
+ rendering a different wafer from the code the reader had just copied, silently.
190
+ `docs/examples/quickstart-data.js` is now the source: the live page and the capture import it,
191
+ so those cannot drift by construction. The Markdown keeps its inline copy deliberately — the
192
+ snippet promises "copy this into an HTML file, no bundler required", which a local import
193
+ would break — and `scripts/check-quickstart-snippet.mjs` (wired into `npm run check`) holds it
194
+ to the fixture, with `--write` to regenerate it. It also fails if either of the other two
195
+ re-inlines the loop. The refactor is provably inert: `quickstart-first-map.png` re-captures
196
+ byte-for-byte identical.
197
+ - **The Quick Start no longer detours into partial-wafer geometry.** The "Partial data needs a
198
+ wafer centre" section — three paragraphs and a second code block, arriving immediately after
199
+ the reader's first successful map — is now a short callout that names the case, names the
200
+ `'partial-coverage'` warning, and links to the guide section that already covers it in full.
201
+ A tutorial's job is the first success; the geometry discussion is a how-to and was already
202
+ written as one. Nothing was deleted, and a "you have now…" line closes the walkthrough.
203
+ - **The Developer Guide's sections are no longer numbered.** `## 3. Loading real data from a
204
+ CSV` is now `## Loading real data from a CSV`, and all 42 links that pointed at the numbered
205
+ anchors — plus the `Guide §N` link labels in `docs/examples/manifest.json`, which name the
206
+ section they lead to instead — were moved with it. The numbering was chronological by feature
207
+ addition rather than by reading order, and it was baked into every anchor
208
+ (`#3-loading-real-data-from-a-csv`), so inserting one section silently broke every link below
209
+ it. `check-doc-links.mjs` landed first precisely so this move could be proven complete rather
210
+ than assumed. The API reference keeps its numbering for now — there the numbers are a real
211
+ hierarchical aid, not an accident of ordering.
212
+
213
+ ### Fixed
214
+
215
+ - **Implausible geometry could hang a render.** `toCanvas` sized its hit-test grid from die size
216
+ alone, so a die size wrong by orders of magnitude — a misread WCR record gave dies ~1e-8 mm
217
+ wide — asked for more cells than an array can hold: every render and resize threw
218
+ `Invalid array length`, and the map never appeared. The grid is now sized by `hitGridDims`
219
+ (internal `hitGrid.ts`), which falls back to the span for non-finite or non-positive sizes and
220
+ caps the grid at max(4096, 4 × dies) cells; a coarser grid only means a hit test looks at a few
221
+ more dies. A non-finite die position no longer indexes cell `NaN`. Found via tsmap
222
+ (WMAP_ISSUES.md #53).
223
+ - **"Lot" was used for any set of wafers, including sets spanning several lots.** A gallery or
224
+ `analyzeWaferLot` call covers whatever wafers were passed, but the panel read "Lot Summary —
225
+ 26 wafers", yield outliers were "lower than the lot median", the trend chart's reference was
226
+ the "lot mean", reports were titled "Lot Summary" / "Lot Findings Report" and the stacked
227
+ tooltip gave "% of lot" — all over a pooled multi-lot load, or one with no lot ID at all. One
228
+ rule now names the population (`stats/population.ts`): "lot" only when every wafer records the
229
+ same lot ID (`Summary — Lot LOT123 · 13 wafers`, "lot median"), otherwise the wafers
230
+ (`Summary — 26 wafers from 2 lots`, "median of all wafers"). The stacked tooltip reads
231
+ "% of stacked wafers"; two gallery messages became lot-neutral. Public API names
232
+ (`analyzeWaferLot`, `LotStatsSummary`, `lotStack`) keep "lot" in its looser sense — the
233
+ glossary now says so. Also corrected: the API reference and developer guide still described
234
+ the gallery panel's long-gone Lot/Wafers tabs, and called its button "Lot findings" rather than
235
+ **Summary panel**.
236
+ - **Different bins were drawn in the same colour.** Soft bins and the Accessible palette hashed the
237
+ bin number into a fixed list, so collisions were certain: among bins 1–16, soft bins 1 and 7 and
238
+ 4 and 6 were identical, as were Accessible bins 4 and 5 and 9 and 13, and only 75 distinct colours
239
+ covered hard bins 1–255. The hand-picked hard bins 1–14 came in near-duplicate pairs (1/12 green,
240
+ 2/9 red, 5/11 blue…), and bins 4 and 11 were ΔE 1.4 apart for a deuteranope. Colours are now
241
+ rank-assigned from measured palettes: no two bins share a colour until the palette is exhausted,
242
+ and then a warning names them. Default: 3 pass + 19 fail colours, every pair ≥ 16 ΔE00. Colour-blind
243
+ safe: 2 pass + 14 fail colours, every pair ≥ 8.8 ΔE00 under all three simulated deficiencies.
244
+ - **Bin colour contradicted `passBins`.** Bin 1 was always green and bin 2 always red, whatever
245
+ `passBins` said — with `passBins: [1, 3]` a passing bin 3 drew orange ("marginal") and a failing
246
+ bin 12 drew green. Pass colours now come from `passBins`, never the number.
247
+ - **The soft-bin failing-die hatch marked the wrong dies.** It tested the plotted *soft* bin number
248
+ against the *hard* pass-bin list. It now judges each die by `diePassStatus`, the rule yield uses.
249
+ - **`WaferMapController.getActiveLegend()` returned colours the map was not drawing.** It hashed bin
250
+ numbers directly, ignoring the selected palette, so a host legend built from it disagreed with the
251
+ map under any non-default scheme. It now reads the view's resolved colours.
252
+ - **`analyzeWaferMap` reported a "correction" for an option that was never set.**
253
+ `{ ...DEFAULT_OPTIONS, ...options }` let an explicit `undefined` overwrite the default, which
254
+ then failed the finite-number test — so the most ordinary thing a host writes (forwarding an
255
+ optional: `{ ringCount: opts.rings }` with `rings` unset) raised an `analysis-option-corrected`
256
+ advisory, and that one is not quiet: it reaches the toolbar's warning indicator and the Summary
257
+ panel's banner. Undefined keys are now dropped before merging, so "not passed" and "passed as
258
+ undefined" mean the same thing. Genuinely bad values are still corrected and still reported.
259
+ - **`buildWaferMap({ testDefs: [] })` silently lost every value plot mode** — and every test
260
+ column in the die list. An empty array is load-bearing downstream (`renderWaferGallery`'s
261
+ reconciled "kept nothing", which no fallback may override by discovering bare test numbers back
262
+ from the dies), but from a HOST it just means "I described none", which is the same statement
263
+ as omitting the field. It is now normalised to `undefined` at that one input boundary; the
264
+ gallery's own signal is unaffected.
265
+ - **The findings expand chevron stayed painted as hovered** after the first hover:
266
+ `wireControlHover` was called on it twice, and the second call snapshotted the first's hover
267
+ colours as the resting state.
268
+ - **Geometry advisories rendered as truncated prose in the collapsed warnings banner.**
269
+ `SHORT_LABEL` still keyed the removed `inferred-pitch` and had no entry for
270
+ `non-standard-diameter` or `diameter-exceeds-die-extent` — the codes that replaced it — so
271
+ every advisory 0.27.0 actually raises fell through to a 57-character truncation of its own
272
+ message. Entries added for all current codes, and a test now checks the table against the
273
+ declared code union so it cannot drift again.
274
+ - **The chrome row painted an empty band** above a map rendered with `showToolbar: false` and no
275
+ identity: it is built unconditionally and its own comment claimed it "collapses to nothing when
276
+ it holds neither", but nothing implemented that. It now hides when it holds nothing visible —
277
+ hidden children included, since `setIdentityVisible(false)` hides in place.
278
+ - **Gallery metadata-mode colours could disagree with the legend beside them.** Each card
279
+ assigned colours by index into the values on its OWN dies, while the shared legend strip
280
+ ranked the union across every card — so a wafer that never exhibited one category shifted
281
+ every later category up a colour and painted it in the colour the legend gave to another.
282
+ New `ViewOptions.metadataValueOrder` (`{ key, values }`, applied only when `key` matches
283
+ `activeMetadataKey`, mirroring `valueRange`'s `{ test, range }` guard) is forwarded by
284
+ `renderWaferMap` and set automatically by `renderWaferGallery`, so every card colours from one
285
+ lot-wide list. Values a card has but the list doesn't are appended, never dropped.
286
+ - The ordering is now one implementation (`collectMetadataValues`) shared by the maps and the
287
+ legend, and the legend stopped deriving its values with `String(raw)` where the maps use
288
+ `metadataCategoricalValue` — a second divergence in the same place, visible on numeric
289
+ metadata.
290
+ - A standalone `renderWaferMap` is unchanged: with no order supplied it still derives one from
291
+ its own dies, which is correct for one wafer.
292
+ - **The histogram resolved an unset "axis includes limits" preference two different ways.**
293
+ Grouped (faceted) treated it as off; ungrouped derived it from the data. The same test with no
294
+ preference therefore included the spec limits in the axis in one view and not the other — the
295
+ range moved under the reader while the toggle stayed put. Both branches now derive it from
296
+ their own population via `shouldIncludeLimitsByDefault`.
297
+ - **The histogram's "Clip outliers" toggle did nothing in the grouped view.** It was rendered
298
+ there but `buildTestHistogramSeries` had no way to take a clip range, so the control applied to
299
+ the ungrouped branch only — in exactly the view where one wild reading does the most damage,
300
+ compressing every group's buckets at once. The builder now takes the same optional `clip` its
301
+ ungrouped twin has, with the same contract (narrows where limits widen; no statistic anywhere
302
+ is computed from a clipped population).
303
+ - **A grouped histogram could kill the Insights rebuild on a real lot.** The faceted axis range
304
+ used `Math.min(...values)`, which passes every die value as a separate argument; ~250k values
305
+ (25 wafers × 10k dies) throws `RangeError: Maximum call stack size exceeded`. Replaced with
306
+ `testValueExtent`, a loop that also avoids materialising the pooled array. The ungrouped branch
307
+ had the same spread and is fixed too.
308
+ - **`summaryPanel: { placement: 'top' | 'bottom' }` clipped its own content silently.** Those
309
+ placements take a fixed 180px band with `overflowY: hidden`, which most wafers' content
310
+ exceeds, so the last visible row was cut mid-line with nothing indicating more existed. All
311
+ placements now scroll, the way `'right'`/`'left'` already did. (Whether this content suits a
312
+ wide-short band at all is a separate open question — see TODO.md.)
313
+ - **Closing and reopening Insights no longer discards the shared axis toggles.** `axisPrefs`
314
+ ("axis includes limits" / "clip outliers") lived inside `renderDistributionsSection`, which
315
+ `render()` rebuilds — so a scope change, or simply toggling Insights off and on, silently
316
+ reverted the user's choice to the default. It now sits at tab level beside the selected test
317
+ and group scope, which were already lifted for the same reason. Affects the gallery too.
318
+ - **The gallery no longer re-renders every card on every toolbar change.**
319
+ `syncSharedMetadataOrder` pushed `metadataValueOrder: undefined` to each card even outside
320
+ `metadata` mode, and each push costs a full view rebuild. It now returns early when there is
321
+ nothing to change, matching `syncSharedValueRange`.
322
+ - **`renderWaferMap`'s Insights view no longer jumps back to the top on reopen.** The tab's
323
+ scroll offset is remembered on close and re-applied over a short window after the rebuild —
324
+ one assignment is not enough, because the cards grow to their measured content over the
325
+ following frames, so the first write is clamped to a container that is still short (212px of
326
+ a requested 300px, measured) and the layout pass can then reset it to 0. A position past the
327
+ target stops the retries: that is the user scrolling, and they own it from then on.
328
+ (Gallery-side, the host page owns the scroller, so there is nothing for the library to
329
+ restore.)
330
+ - Corrected `charts/boxplot.ts`'s header comment, which still said click-to-open-wafer was
331
+ unimplemented long after it shipped.
332
+
333
+ ---
334
+
25
335
  ## [0.27.0] — 2026-09-09
26
336
 
27
337
  ### 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
@@ -1 +1 @@
1
- import{getColorScheme as we}from"../../renderer/colorSchemes.js";import{categorical as Me}from"./palette.js";import{buildScatterData as ve,buildScatterDataGrouped as Le}from"../../stats/scatter.js";import{pearsonOfPairs as ke}from"../../stats/correlation.js";import{buildFacetTable as Ce}from"../../stats/facets.js";import{SPACE as T,RADIUS as Ae,fontPx as U,FONT as q,CLR as H}from"../toolbar.js";import{cardShell as De,observeResize as Be,makeTooltip as Oe,attachChartTip as $e,makeTestSelect as Pe,makeWaferSelect as He,chartFillHeight as We,applyCanvasFlow as Ee,drawAxisUnit as Fe,resolveChartCanvasColors as Ie,makeAxisFormat as ae,makeSeriesLegendItem as Re,prepareCanvas as Ne}from"./chartShell.js";const c=52,ze=16,s=16,je=44;export function renderScatterPanel(W){const{title:re="Test scatter",items:C,testDefs:J,colorScheme:ce="default",onSaveImage:de,groups:E}=W,{card:i,body:L,controlsRow:Q}=De(re,de,W.ownerDocument),F=J.filter(t=>t.testNumber!==void 0),y=!!(E&&E.length>0),V=(E??[]).map(t=>t.key),me=new Map(V.map((t,n)=>[t,n]));let p=W.xTestNumber??F[0]?.testNumber??null,x=W.yTestNumber??F[1]?.testNumber??p,A=null;const Z=()=>A!==null?C[A]?[C[A]]:[]:C;function ee(t,n,o){const l=i.ownerDocument.createElement("label");Object.assign(l.style,{display:"inline-flex",alignItems:"center",gap:T.xs,fontSize:q.body,color:H.label});const g=i.ownerDocument.createElement("span");g.textContent=t;const m=Pe(F,n,o,{maxWidth:"180px",emptyText:"No tests",ownerDocument:i.ownerDocument});return l.append(g,m),{wrap:l,select:m}}const{wrap:fe,select:pe}=ee("X:",p,t=>{p=t,O()}),{wrap:ue,select:xe}=ee("Y:",x,t=>{x=t,O()});Q.append(fe,ue),!y&&C.length>1&&Q.appendChild(He(C,A,t=>{A=t,O()},{ownerDocument:i.ownerDocument}));const D=i.ownerDocument.createElement("div");Object.assign(D.style,{color:H.warnText,background:H.warnBg,border:`1px solid ${H.warnBorder}`,borderRadius:Ae.control,padding:`${T.xs} ${T.md}`,fontSize:q.body,marginBottom:T.xs,display:"none"}),i.insertBefore(D,L);function te(){const t=y?[]:Ce(Z(),{facetableOnly:!0}).filter(n=>n.splittable).map(n=>n.label);t.length>0?(D.textContent=`\u26A0 Mixed ${t.join(", ")} across these points \u2014 trends here may be confounded (Simpson's paradox). Use Group by, or the Wafer picker, to narrow to a like-for-like set.`,D.style.display=""):D.style.display="none"}te();const G=i.ownerDocument.createElement("div");Object.assign(G.style,{color:H.label,fontSize:q.body,marginBottom:T.xs}),i.insertBefore(G,L);function _(){const t=y?"One point per die \xB7 coloured by group \xB7 click legend to filter":"One point per die across all wafers \xB7 coloured by hard bin \xB7 click legend to filter",n=h.size===0?f:f.filter(g=>h.has(I(g))),{r:o,n:l}=ke(n);G.textContent=o===null?`${t} \xB7 n = ${l.toLocaleString()}`:`${t} \xB7 r = ${o.toFixed(3)} \xB7 n = ${l.toLocaleString()}`}const he=we(ce),{forBin:ge}=he,I=t=>y?t.group??"\u2014":String(t.hbin??0),ne=t=>y?Me(me.get(t)??0):ge(Number(t)),oe=t=>y?t:t==="0"?"No bin data":`Bin ${t}`,h=new Set,B=i.ownerDocument.createElement("div");Object.assign(B.style,{display:"flex",flexWrap:"wrap",gap:T.sm,marginTop:T.md,marginBottom:T.xl}),L.appendChild(B);const k=i.ownerDocument.createElement("canvas");k.style.display="block",k.style.cursor="crosshair",L.appendChild(k);const be=Oe(i);let f=[],w=0,R=1,M=0,N=1;function z(t){const n=J.find(o=>o.testNumber===t);return{unit:n?.unit,limitLow:n?.limitLow,limitHigh:n?.limitHigh}}function ye(){const t=L.clientWidth,n=Math.max(200,Math.min(400,t*.65)),o=We(i,L,k,n);return{w:t,h:o,plotW:Math.max(10,t-c-ze),plotH:Math.max(10,o-s-je)}}const X=new Map;function ie(){for(const[t,n]of X){const o=h.has(t);n.setState({selected:o,dimmed:h.size>0&&!o})}}function le(t){B.innerHTML="",X.clear(),h.clear();for(const n of t){const o=Re(oe(n),ne(n),i.ownerDocument),l=o.el;l.dataset.cat=n,X.set(n,o),$e(l,i,be,`${oe(n)} \u2014 click to filter`),l.addEventListener("click",()=>{h.has(n)?h.delete(n):h.add(n),ie(),_(),j()}),B.appendChild(l)}ie()}function j(){Ee(k,B);const t=Ie(i),n=R-w,o=N-M,{w:l,h:g,plotW:m,plotH:d}=ye(),$=Ne(k,i,l,g);if(!$)return;const{ctx:e}=$;if(f.length===0){e.font=`${U()}px system-ui, sans-serif`,e.fillStyle=t.textMuted,e.textAlign="center",e.textBaseline="middle",e.fillText("No data \u2014 select two parametric tests with values.",l/2,g/2);return}const b=4,Y=ae(Math.max(Math.abs(w),Math.abs(R)),p!==null?z(p).unit:void 0),K=ae(Math.max(Math.abs(M),Math.abs(N)),x!==null?z(x).unit:void 0);e.font=`${U(-1)}px system-ui, sans-serif`,e.strokeStyle=t.border,e.lineWidth=.5,e.fillStyle=t.textMuted;for(let a=0;a<=b;a++){const S=w+n*a/b,r=c+a/b*m;e.beginPath(),e.moveTo(r,s),e.lineTo(r,s+d),e.stroke(),e.textAlign="center",e.textBaseline="top",e.fillText(Y.tick(S),r,s+d+4);const v=M+o*a/b,u=s+(1-a/b)*d;e.beginPath(),e.moveTo(c,u),e.lineTo(c+m,u),e.stroke(),e.textAlign="right",e.textBaseline="middle",e.fillText(K.tick(v),c-4,u)}Y.unitLabel&&Fe(e,Y.unitLabel,c+m/2,s+d+24,t.textMuted),K.unitLabel&&(e.save(),e.font=`${U(-1)}px system-ui, sans-serif`,e.fillStyle=t.textMuted,e.textAlign="center",e.textBaseline="middle",e.translate(6,s+d/2),e.rotate(-Math.PI/2),e.fillText(`(${K.unitLabel})`,0,0),e.restore());const P=h.size===0?f:f.filter(a=>h.has(I(a))),se=P.length>5e3?Math.ceil(P.length/5e3):1;e.globalAlpha=Math.max(.15,Math.min(.7,200/(P.length/se)));for(let a=0;a<P.length;a+=se){const S=P[a],r=c+(S.x-w)/n*m,v=s+(1-(S.y-M)/o)*d;e.beginPath(),e.arc(r,v,2.5,0,Math.PI*2),e.fillStyle=ne(I(S)),e.fill()}if(e.globalAlpha=1,p!==null&&x!==null){const a=z(p),S=z(x);e.strokeStyle=t.textMuted,e.fillStyle=t.textMuted,e.lineWidth=1,e.setLineDash([4,3]),e.font=`${U(-1)}px system-ui, sans-serif`,e.textBaseline="top";for(const[r,v]of[[a.limitLow,"LSL"],[a.limitHigh,"USL"]]){if(r===void 0||r<w||r>R)continue;const u=c+(r-w)/n*m;e.beginPath(),e.moveTo(u,s),e.lineTo(u,s+d),e.stroke(),e.textAlign="left",e.fillText(v,u+2,s+2)}for(const[r,v]of[[S.limitLow,"LSL"],[S.limitHigh,"USL"]]){if(r===void 0||r<M||r>N)continue;const u=s+(1-(r-M)/o)*d;e.beginPath(),e.moveTo(c,u),e.lineTo(c+m,u),e.stroke(),e.textAlign="left",e.fillText(v,c+3,u+2)}e.setLineDash([])}e.strokeStyle=t.border,e.lineWidth=1,e.beginPath(),e.moveTo(c,s),e.lineTo(c,s+d),e.moveTo(c,s+d),e.lineTo(c+m,s+d),e.stroke()}function O(){if(te(),F.length<2||p===null||x===null){f=[],le([]),_(),j();return}f=y?Le(E,p,x):ve(Z(),p,x);let t;if(y){const n=new Set(f.map(I));t=V.filter(o=>n.has(o))}else t=Array.from(new Set(f.map(n=>n.hbin??0))).sort((n,o)=>n-o).map(String);if(le(t),_(),f.length>0){const n=f.map(b=>b.x),o=f.map(b=>b.y),l=Math.min(...n),g=Math.max(...n),m=Math.min(...o),d=Math.max(...o),$=(g-l)*.05||1,e=(d-m)*.05||1;w=l-$,R=g+$,M=m-e,N=d+e}j()}const Se=Be(i,()=>j());O();function Te(t,n){p=t,x=n,pe.value=String(t),xe.value=String(n),O()}return{card:i,setXY:Te,destroy:()=>Se.disconnect()}}
1
+ import{NO_DATA_FILL as ae}from"../../renderer/colorMap.js";import{categorical as Te}from"./palette.js";import{buildScatterData as we,buildScatterDataGrouped as Me}from"../../stats/scatter.js";import{pearsonOfPairs as ve}from"../../stats/correlation.js";import{buildFacetTable as Le}from"../../stats/facets.js";import{SPACE as T,RADIUS as ke,fontPx as j,FONT as q,CLR as H}from"../toolbar.js";import{cardShell as Ce,observeResize as Ae,makeTooltip as De,attachChartTip as Oe,makeTestSelect as $e,makeWaferSelect as Be,chartFillHeight as Pe,applyCanvasFlow as He,drawAxisUnit as We,resolveChartCanvasColors as Ee,makeAxisFormat as re,makeSeriesLegendItem as Fe,prepareCanvas as Ie}from"./chartShell.js";const c=52,Ne=16,s=16,Re=44;export function renderScatterPanel(W){const{title:ce="Test scatter",items:C,testDefs:J,binColors:de,onSaveImage:me,groups:E}=W,{card:i,body:L,controlsRow:Q}=Ce(ce,me,W.ownerDocument),F=J.filter(t=>t.testNumber!==void 0),y=!!(E&&E.length>0),V=(E??[]).map(t=>t.key),fe=new Map(V.map((t,n)=>[t,n]));let p=W.xTestNumber??F[0]?.testNumber??null,x=W.yTestNumber??F[1]?.testNumber??p,A=null;const Z=()=>A!==null?C[A]?[C[A]]:[]:C;function ee(t,n,o){const l=i.ownerDocument.createElement("label");Object.assign(l.style,{display:"inline-flex",alignItems:"center",gap:T.xs,fontSize:q.body,color:H.label});const g=i.ownerDocument.createElement("span");g.textContent=t;const m=$e(F,n,o,{maxWidth:"180px",emptyText:"No tests",ownerDocument:i.ownerDocument});return l.append(g,m),{wrap:l,select:m}}const{wrap:pe,select:ue}=ee("X:",p,t=>{p=t,$()}),{wrap:xe,select:he}=ee("Y:",x,t=>{x=t,$()});Q.append(pe,xe),!y&&C.length>1&&Q.appendChild(Be(C,A,t=>{A=t,$()},{ownerDocument:i.ownerDocument}));const D=i.ownerDocument.createElement("div");Object.assign(D.style,{color:H.warnText,background:H.warnBg,border:`1px solid ${H.warnBorder}`,borderRadius:ke.control,padding:`${T.xs} ${T.md}`,fontSize:q.body,marginBottom:T.xs,display:"none"}),i.insertBefore(D,L);function te(){const t=y?[]:Le(Z(),{facetableOnly:!0}).filter(n=>n.splittable).map(n=>n.label);t.length>0?(D.textContent=`\u26A0 Mixed ${t.join(", ")} across these points \u2014 trends here may be confounded (Simpson's paradox). Use Group by, or the Wafer picker, to narrow to a like-for-like set.`,D.style.display=""):D.style.display="none"}te();const U=i.ownerDocument.createElement("div");Object.assign(U.style,{color:H.label,fontSize:q.body,marginBottom:T.xs}),i.insertBefore(U,L);function G(){const t=y?"One point per die \xB7 coloured by group \xB7 click legend to filter":"One point per die across all wafers \xB7 coloured by hard bin \xB7 click legend to filter",n=h.size===0?f:f.filter(g=>h.has(I(g))),{r:o,n:l}=ve(n);U.textContent=o===null?`${t} \xB7 n = ${l.toLocaleString()}`:`${t} \xB7 r = ${o.toFixed(3)} \xB7 n = ${l.toLocaleString()}`}const I=t=>y?t.group??"\u2014":String(t.hbin??0),ne=t=>y?Te(fe.get(t)??0):t==="0"?ae:de?.get(Number(t))??ae,oe=t=>y?t:t==="0"?"No bin data":`Bin ${t}`,h=new Set,O=i.ownerDocument.createElement("div");Object.assign(O.style,{display:"flex",flexWrap:"wrap",gap:T.sm,marginTop:T.md,marginBottom:T.xl}),L.appendChild(O);const k=i.ownerDocument.createElement("canvas");k.style.display="block",k.style.cursor="crosshair",L.appendChild(k);const ge=De(i);let f=[],w=0,N=1,M=0,R=1;function z(t){const n=J.find(o=>o.testNumber===t);return{unit:n?.unit,limitLow:n?.limitLow,limitHigh:n?.limitHigh}}function be(){const t=L.clientWidth,n=Math.max(200,Math.min(400,t*.65)),o=Pe(i,L,k,n);return{w:t,h:o,plotW:Math.max(10,t-c-Ne),plotH:Math.max(10,o-s-Re)}}const X=new Map;function ie(){for(const[t,n]of X){const o=h.has(t);n.setState({selected:o,dimmed:h.size>0&&!o})}}function le(t){O.innerHTML="",X.clear(),h.clear();for(const n of t){const o=Fe(oe(n),ne(n),i.ownerDocument),l=o.el;l.dataset.cat=n,X.set(n,o),Oe(l,i,ge,`${oe(n)} \u2014 click to filter`),l.addEventListener("click",()=>{h.has(n)?h.delete(n):h.add(n),ie(),G(),_()}),O.appendChild(l)}ie()}function _(){He(k,O);const t=Ee(i),n=N-w,o=R-M,{w:l,h:g,plotW:m,plotH:d}=be(),B=Ie(k,i,l,g);if(!B)return;const{ctx:e}=B;if(f.length===0){e.font=`${j()}px system-ui, sans-serif`,e.fillStyle=t.textMuted,e.textAlign="center",e.textBaseline="middle",e.fillText("No data \u2014 select two parametric tests with values.",l/2,g/2);return}const b=4,Y=re(Math.max(Math.abs(w),Math.abs(N)),p!==null?z(p).unit:void 0),K=re(Math.max(Math.abs(M),Math.abs(R)),x!==null?z(x).unit:void 0);e.font=`${j(-1)}px system-ui, sans-serif`,e.strokeStyle=t.border,e.lineWidth=.5,e.fillStyle=t.textMuted;for(let a=0;a<=b;a++){const S=w+n*a/b,r=c+a/b*m;e.beginPath(),e.moveTo(r,s),e.lineTo(r,s+d),e.stroke(),e.textAlign="center",e.textBaseline="top",e.fillText(Y.tick(S),r,s+d+4);const v=M+o*a/b,u=s+(1-a/b)*d;e.beginPath(),e.moveTo(c,u),e.lineTo(c+m,u),e.stroke(),e.textAlign="right",e.textBaseline="middle",e.fillText(K.tick(v),c-4,u)}Y.unitLabel&&We(e,Y.unitLabel,c+m/2,s+d+24,t.textMuted),K.unitLabel&&(e.save(),e.font=`${j(-1)}px system-ui, sans-serif`,e.fillStyle=t.textMuted,e.textAlign="center",e.textBaseline="middle",e.translate(6,s+d/2),e.rotate(-Math.PI/2),e.fillText(`(${K.unitLabel})`,0,0),e.restore());const P=h.size===0?f:f.filter(a=>h.has(I(a))),se=P.length>5e3?Math.ceil(P.length/5e3):1;e.globalAlpha=Math.max(.15,Math.min(.7,200/(P.length/se)));for(let a=0;a<P.length;a+=se){const S=P[a],r=c+(S.x-w)/n*m,v=s+(1-(S.y-M)/o)*d;e.beginPath(),e.arc(r,v,2.5,0,Math.PI*2),e.fillStyle=ne(I(S)),e.fill()}if(e.globalAlpha=1,p!==null&&x!==null){const a=z(p),S=z(x);e.strokeStyle=t.textMuted,e.fillStyle=t.textMuted,e.lineWidth=1,e.setLineDash([4,3]),e.font=`${j(-1)}px system-ui, sans-serif`,e.textBaseline="top";for(const[r,v]of[[a.limitLow,"LSL"],[a.limitHigh,"USL"]]){if(r===void 0||r<w||r>N)continue;const u=c+(r-w)/n*m;e.beginPath(),e.moveTo(u,s),e.lineTo(u,s+d),e.stroke(),e.textAlign="left",e.fillText(v,u+2,s+2)}for(const[r,v]of[[S.limitLow,"LSL"],[S.limitHigh,"USL"]]){if(r===void 0||r<M||r>R)continue;const u=s+(1-(r-M)/o)*d;e.beginPath(),e.moveTo(c,u),e.lineTo(c+m,u),e.stroke(),e.textAlign="left",e.fillText(v,c+3,u+2)}e.setLineDash([])}e.strokeStyle=t.border,e.lineWidth=1,e.beginPath(),e.moveTo(c,s),e.lineTo(c,s+d),e.moveTo(c,s+d),e.lineTo(c+m,s+d),e.stroke()}function $(){if(te(),F.length<2||p===null||x===null){f=[],le([]),G(),_();return}f=y?Me(E,p,x):we(Z(),p,x);let t;if(y){const n=new Set(f.map(I));t=V.filter(o=>n.has(o))}else t=Array.from(new Set(f.map(n=>n.hbin??0))).sort((n,o)=>n-o).map(String);if(le(t),G(),f.length>0){const n=f.map(b=>b.x),o=f.map(b=>b.y),l=Math.min(...n),g=Math.max(...n),m=Math.min(...o),d=Math.max(...o),B=(g-l)*.05||1,e=(d-m)*.05||1;w=l-B,N=g+B,M=m-e,R=d+e}_()}const ye=Ae(i,()=>_());$();function Se(t,n){p=t,x=n,ue.value=String(t),he.value=String(n),$()}return{card:i,setXY:Se,destroy:()=>ye.disconnect()}}
@@ -9,6 +9,13 @@ export interface TrendPanelOptions {
9
9
  onSaveImage?: SaveImageHandler;
10
10
  /** Click a point to open that wafer's map on this test. */
11
11
  onOpen?: (key: number, testNumber: number) => void;
12
+ /**
13
+ * What the dashed reference line is, as a noun phrase — "lot mean" when the
14
+ * items are one lot, else "mean of all wafers" (see stats/population.ts).
15
+ * The panel sees no wafer metadata, so the host names it. Default
16
+ * "mean of all wafers", which is true of any set.
17
+ */
18
+ centreLabel?: string;
12
19
  /** Initial axis toggles, shared with the sibling distribution panels. */
13
20
  axisPrefs?: AxisPrefs;
14
21
  /** Fired when the user changes an axis toggle here, so siblings can follow. */