@wafertools/wafermap 0.21.1 → 0.23.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 (50) hide show
  1. package/AGENTS.md +181 -0
  2. package/CHANGELOG.md +261 -1
  3. package/README.md +9 -1
  4. package/dist/packages/canvas-adapter/charts/histogram.js +1 -1
  5. package/dist/packages/canvas-adapter/charts/scatter.js +1 -1
  6. package/dist/packages/canvas-adapter/dieList.d.ts +41 -0
  7. package/dist/packages/canvas-adapter/dieList.js +2 -0
  8. package/dist/packages/canvas-adapter/icons.js +1 -1
  9. package/dist/packages/canvas-adapter/index.d.ts +4 -0
  10. package/dist/packages/canvas-adapter/index.js +1 -1
  11. package/dist/packages/canvas-adapter/maplessSummary.d.ts +34 -0
  12. package/dist/packages/canvas-adapter/maplessSummary.js +1 -0
  13. package/dist/packages/canvas-adapter/renderWaferGallery.d.ts +9 -11
  14. package/dist/packages/canvas-adapter/renderWaferGallery.js +8 -8
  15. package/dist/packages/canvas-adapter/renderWaferMap.d.ts +31 -1
  16. package/dist/packages/canvas-adapter/renderWaferMap.js +1 -1
  17. package/dist/packages/canvas-adapter/summaryPanel.d.ts +13 -1
  18. package/dist/packages/canvas-adapter/summaryPanel.js +3 -3
  19. package/dist/packages/canvas-adapter/toolbar.d.ts +4 -1
  20. package/dist/packages/canvas-adapter/toolbar.js +1 -1
  21. package/dist/packages/canvas-adapter/userGuideHtml.d.ts +1 -1
  22. package/dist/packages/canvas-adapter/userGuideHtml.js +59 -22
  23. package/dist/packages/canvas-adapter/version.d.ts +2 -2
  24. package/dist/packages/canvas-adapter/version.js +1 -1
  25. package/dist/packages/canvas-adapter/warnings.d.ts +57 -0
  26. package/dist/packages/canvas-adapter/warnings.js +1 -0
  27. package/dist/packages/core/classify.d.ts +6 -2
  28. package/dist/packages/core/dies.d.ts +67 -7
  29. package/dist/packages/core/dies.js +1 -1
  30. package/dist/packages/core/probe.d.ts +5 -0
  31. package/dist/packages/core/probe.js +1 -1
  32. package/dist/packages/core/transforms.d.ts +4 -4
  33. package/dist/packages/core/transforms.js +1 -1
  34. package/dist/packages/renderer/buildView.d.ts +3 -3
  35. package/dist/packages/renderer/buildView.js +1 -1
  36. package/dist/packages/renderer/buildWaferMap.d.ts +40 -9
  37. package/dist/packages/renderer/buildWaferMap.js +1 -1
  38. package/dist/packages/renderer/index.d.ts +1 -1
  39. package/dist/packages/renderer/index.js +1 -1
  40. package/dist/packages/stats/analyzeWaferLot.js +1 -1
  41. package/dist/packages/stats/analyzeWaferMap.js +1 -1
  42. package/dist/packages/stats/clusterDetection.d.ts +2 -2
  43. package/dist/packages/stats/patternClassification.d.ts +2 -2
  44. package/dist/packages/stats/regions.d.ts +6 -6
  45. package/dist/packages/stats/regions.js +1 -1
  46. package/dist/packages/stats/renderFindingsReport.js +15 -15
  47. package/dist/packages/stats/renderSummaryReport.js +17 -17
  48. package/dist/packages/stats/types.d.ts +34 -4
  49. package/llms.txt +39 -0
  50. package/package.json +12 -8
package/AGENTS.md ADDED
@@ -0,0 +1,181 @@
1
+ # wafermap — rules for AI coding agents
2
+
3
+ Guidance for AI tools (Claude Code, Codex, Copilot, Cursor, …) writing code that
4
+ **uses** `@wafertools/wafermap`. Paste the rules below into your project's own
5
+ agent config so they are loaded whenever the agent works on wafer map code.
6
+
7
+ This library renders semiconductor wafer maps. Its output drives yield calls, lot
8
+ dispositions and process changes, so a plot that is *plausibly but silently wrong*
9
+ is the expensive failure — worse than one that throws. Most rules here exist
10
+ because the obvious-looking code produces exactly that.
11
+
12
+ <!-- RULES:START -->
13
+
14
+ ## wafermap — usage rules
15
+
16
+ `@wafertools/wafermap` renders wafer maps from semiconductor die test data.
17
+ Wrong-but-plausible output drives real yield and lot decisions, so prefer failing
18
+ loudly over guessing.
19
+
20
+ ### Entry points
21
+
22
+ - `@wafertools/wafermap` — `buildWaferMap()`, geometry, `registerColorScheme()`. Pure, no DOM, server-safe.
23
+ - `@wafertools/wafermap/render` — `renderWaferMap()`, `renderWaferGallery()`, `toCanvas()`. Needs the DOM.
24
+ - `@wafertools/wafermap/stats` — `analyzeWaferMap()`, `analyzeWaferLot()`. Pure analysis.
25
+ - `@wafertools/wafermap/worker` — `createWafermapWorker()` for off-main-thread builds.
26
+
27
+ Default path: `buildWaferMap()` once when data loads, then `renderWaferMap()` for a
28
+ single wafer or `renderWaferGallery()` for several. Reach for `toCanvas()`/`buildView()`
29
+ only when you need the low-level pipeline — they give up the toolbar and every UI
30
+ correctness guarantee that comes with it.
31
+
32
+ ### Traps that produce silently wrong maps
33
+
34
+ - **Never `die.hbin ?? 0` or `die.sbin ?? 0`.** A missing bin is not bin 0 — it is
35
+ no-data, and must render grey. Defaulting to 0 invents a bin and changes the
36
+ yield number. Leave the field absent.
37
+ - **`x` and `y` are prober step positions (integers), not millimetres.** Pass them
38
+ through unchanged; `dieConfig.width`/`height` convert to physical units. Do not
39
+ pre-multiply. The geometry inputs are `waferConfig` (type `WaferConfig`) and
40
+ `dieConfig` (type `DieConfig`) — both optional, both inferred when omitted.
41
+ - **`passBins` decides both the yield number and the wording of its label.** Set it
42
+ from the actual test program. Do not assume `[1]`.
43
+ - **`testValues` is keyed by test number**, e.g. `{ 1050: 0.42 }` — not a positional
44
+ array. `activeTest` likewise takes a *test number* (`1050`), not an index.
45
+ - **Functional tests (`testType: 'F'`) have no measured value.** Read their verdicts
46
+ only via `getTestPassStatus(die, testNumber, def)`; never read `die.testPass`
47
+ directly and never interpret a 0/1 in `testValues`. A missing verdict is no-data,
48
+ never a fail.
49
+ - **Show users `die.x` / `die.y` only.** Never surface internal display or
50
+ transformed coordinates in tooltips, labels or reports, whatever the rotation or
51
+ flip state.
52
+ - **A die with test results is always fully on the wafer.** A prober only steps to
53
+ sites that fit. Never recompute a `partial` flag by testing die corners against
54
+ the wafer circle — that manufactures fake partial dies which are then greyed out
55
+ and dropped from yield. A die outside the wafer means the *geometry* is wrong.
56
+ - **Check `result.warnings` and `summary.stats.warnings`.** Both carry
57
+ `WaferWarning` — `{ code, message, severity }`. Branch on `code`, never on the
58
+ prose. **The two differ in shape and the difference throws:**
59
+ `result.warnings` is required and always an array (`[]` when clean), but
60
+ `summary.stats.warnings` is *optional* and is `undefined` when there is nothing
61
+ to report. Write `summary.stats.warnings?.length` — a bare
62
+ `summary.stats.warnings.length` is a TypeError on every clean wafer, which is
63
+ most of them, so it will pass your testing and fail in production.
64
+ Geometry advisories are severity `'error'`: they mean dies may be drawn in
65
+ the wrong place. The renderers surface these themselves in a toolbar indicator, so
66
+ do NOT hand-roll a second display — pass
67
+ `warnings: { display: false, onWarning }` if the app has its own notification UI.
68
+ (`result.inference.warnings` is a deprecated string mirror; do not use it.)
69
+ - **Give the container a resolved height.** `renderWaferMap` fills its container.
70
+ A bare block-flow `<div>` is fine — it grows to the canvas. The real failure is a
71
+ flex/grid child whose ancestors never resolve a height: it stays 0-tall and the
72
+ map is invisible. The library detects exactly that case after layout settles and
73
+ `console.warn`s with the fix, so read the console before debugging further. Either
74
+ give the container a real CSS height, or pass `{ height: 600 }` in the render
75
+ options and the library will size it for you.
76
+
77
+ ### API facts that are easy to guess wrong
78
+
79
+ - `PlotMode` values are camelCase: `'hardBin'`, `'softBin'`, `'value'`, `'metadata'`,
80
+ `'stackedValues'`, `'stackedBins'`, `'stackedSoftBins'`. Never snake_case.
81
+ - `retestCount` is the total probe count — `2` means probed twice. Do not add 1.
82
+ - `retestPolicy: 'best'`/`'worst'` is pass/fail-aware via `passBins`; bin number only
83
+ breaks ties within a category.
84
+ - Hard bins (`hbin`) and soft bins (`sbin`) are independent number spaces. Never merge them.
85
+ - Build once, render many: `buildWaferMap()` handles data + geometry; re-render UI
86
+ changes through the controller's `setOptions()`, not by rebuilding.
87
+ - `result.view` is internal. Use the promoted fields: `result.plotMode`,
88
+ `result.metadata`, `result.isLotStack`, `result.hbinDefs`, `result.sbinDefs`,
89
+ `result.testDefs`.
90
+ - Die keys come from `getDieKey(die)`. A hand-rolled `` `${x},${y}` `` breaks
91
+ click-to-highlight silently, because findings carry `dieKeys` in that exact format.
92
+ - `stats.warnings` is `WaferWarning[]` (it was `string[]` before 0.22.0). Read
93
+ `w.message` to display, branch on `w.code`. Code that calls a string method on an
94
+ entry — `warnings[0].includes('…')` — is the old shape and will throw.
95
+ - **`summary.findings` is the complete list and contains restatements of the same
96
+ fact.** Building a list for a human to read? Exclude what other findings absorb:
97
+ `const absorbed = new Set(summary.findings.flatMap(f => f.absorbedIds ?? []))`.
98
+ Skip that and one edge failure is reported up to three times per region — a hard
99
+ bin row, its soft-bin twin, and the yield row that restates the pass bin. Do NOT
100
+ use `relatedIds` for this; it is a different relationship and some ids it names
101
+ no longer exist in `findings`.
102
+
103
+ ### Scale: do not turn this into a data explorer
104
+
105
+ The most common performance mistake is treating the library as somewhere to dump
106
+ an entire test program and browse it. It is a *renderer* — it analyses everything
107
+ it is handed, because it has no way to know which tests anyone will look at.
108
+
109
+ - **Pass only the tests you will actually chart or analyse in `testDefs`.** A real
110
+ parametric program can carry hundreds of tests while the user ever looks at a
111
+ handful. Cost scales with test count, and test correlation scales
112
+ *quadratically*: on a ~1,000-die wafer, `enableTestValueAnalysis` costs ~25 ms at
113
+ 6 tests and ~91 ms at 60, while the correlation matrix goes from 15 pairs to 1,770.
114
+ - **Do not "load everything, filter in the UI".** Filtering after the fact means you
115
+ already paid for the parse, the transfer and the analysis.
116
+ - **The right shape is pre-scan → select → load.** Scan the source for which test
117
+ numbers exist (and their names/limits if available), let the user choose, then
118
+ parse and build only the chosen tests. A scan that reads test identity without
119
+ reading every value is dramatically cheaper than a full load, and it is what makes
120
+ a large file feel instant.
121
+ - **Above 250 discovered tests, `analyzeWaferMap` gives up on test-value analysis
122
+ entirely** — it returns no test findings rather than a trimmed set, and records a
123
+ `WaferWarning` with code `'test-count-capped'` in `stats.warnings`. Silence is not
124
+ success here: an empty findings list is indistinguishable from "nothing to report"
125
+ unless you check. Pass `testNumbers: [...]` to scope the analysis explicitly.
126
+ - **Reach for options deliberately.** Plain `analyzeWaferMap()` is cheap;
127
+ `computePerTestStats` is modest; `enableTestValueAnalysis` is the expensive one
128
+ (roughly 10× the base analysis on a large wafer) and exists to find spatial
129
+ patterns automatically — do not enable it by default just because it sounds good.
130
+ - **A Web Worker buys responsiveness, not speed.** `createWafermapWorker` copies data
131
+ across `postMessage`, so total time goes *up*. Use it when a build would otherwise
132
+ visibly freeze the page, not for small datasets.
133
+ - **In a gallery, pass `perWaferSummaries` to `analyzeWaferLot`** so it reuses the
134
+ per-wafer analysis you already ran instead of redoing it.
135
+
136
+ ### Removed — do not emit these
137
+
138
+ | Never | Use instead |
139
+ | --- | --- |
140
+ | `DieResult.values` / `Die.values` | `testValues` (keyed by test number) |
141
+ | `TestDef.index` | `TestDef.testNumber` (required) |
142
+ | `ViewOptions.colorBySpec` | `passFailDisplay: 'spec'` |
143
+ | `ViewOptions.testIndex` | `activeTest` |
144
+ | `mountWaferCanvas` | `renderWaferMap` |
145
+ | `HARD_BIN_COLORS` / `SOFT_BIN_COLORS` | `BIN_PALETTE` |
146
+ | `GalleryItem` | `WaferMapDisplayItem` |
147
+ | `MountOptions` | `RenderOptions` |
148
+ | `WaferCanvasController` | `WaferMapController` |
149
+ | `CanvasHitTarget` | `HitTarget` |
150
+ | `buildScene` / `BuildSceneOptions` / `SceneOptions` | `buildView` / `ViewOptions` |
151
+ | `WaferFlat`, field `flat` | `WaferNotch`, field `notch` |
152
+ | `isInsideWaferWithFlat` | `isInsideWafer` |
153
+ | `DieSample` / `WaferMapPoint` | `DieResult` |
154
+ | `colorScheme: 'color'` | `colorScheme: 'default'` |
155
+ | `plotMode: 'specLimit'` | `passFailDisplay: 'spec'` |
156
+ | standalone `getDieAtPoint` | `hitTarget.getDieAtPoint` from `toCanvas()` |
157
+ | `RenderOptions.tooltipTestLimit` | (was a no-op; nothing replaces it) |
158
+
159
+ Passing a removed option is a type error, and is ignored at runtime. Do not add
160
+ compatibility shims for them.
161
+
162
+ ### Terminology in user-facing text
163
+
164
+ - Never write "channel" — use "index", "slot" or "test". "Channel" is tester
165
+ hardware jargon that confuses the engineers reading these maps.
166
+ - Label what is actually shown. Name the real pass bins rather than assuming bin 1;
167
+ say "Hard Bin Breakdown" not "Bin Breakdown"; identify an aggregated or filtered
168
+ population (`N=50`, "6 wafers · mean") so nobody mistakes a lot stack for one wafer.
169
+
170
+ <!-- RULES:END -->
171
+
172
+ ## Where to look
173
+
174
+ - [API reference](https://wafertools.github.io/wafermap/api/) — every type, option and return value
175
+ - [Developer guide](https://wafertools.github.io/wafermap/guide/) — worked walkthroughs
176
+ - [Troubleshooting](https://wafertools.github.io/wafermap/troubleshooting/)
177
+ - [Examples](https://wafertools.github.io/wafermap/examples/) — 20 runnable pages, also
178
+ [downloadable](https://wafertools.github.io/wafermap/wafermap-examples.zip) to run offline
179
+
180
+ When a rule here and the API reference disagree, the API reference wins — tell the
181
+ user, so this file gets fixed.
package/CHANGELOG.md CHANGED
@@ -19,15 +19,275 @@ under `### Breaking`.
19
19
 
20
20
  ---
21
21
 
22
- ## [Unreleased]
22
+ ## [0.23.0] — 2026-08-16
23
+
24
+ ### Breaking
25
+
26
+ - **`Die.x`/`y`/`physX`/`physY` are now optional**, not `number`. Existing code
27
+ that reads `die.x` as a bare number (e.g. arithmetic without a guard) now gets
28
+ a type error under strict TS — the runtime value for already-positioned data
29
+ is unchanged, only the type widened. New `isPositionedDie(die): die is PositionedDie`
30
+ (`@wafertools/wafermap/core`) narrows it back; every spatial function in the
31
+ library (region builders, cluster/pattern detection, `buildView`) now takes
32
+ `PositionedDie[]` rather than `Die[]`, so a caller passing unfiltered `Die[]`
33
+ into one of these will see the type error at the call site rather than a
34
+ runtime surprise. Its sibling `hasPosition(die)` is the looser guard for
35
+ inputs that carry `x`/`y` but no `physX`/`physY` (a raw `DieResult`, before
36
+ layout); use `isPositionedDie` for anything holding built `Die`s.
37
+
38
+ ### Added
39
+
40
+ - **`renderWaferMap` accepts a `RenderableWaferMap`** — `{ wafer, dies }` plus
41
+ every other `WaferMapResult` field as optional — rather than requiring a full
42
+ `WaferMapResult`. A `WaferMapResult` still satisfies it, so no caller needs to
43
+ change. This makes the gallery's own use honest: it renders each card through
44
+ `renderWaferMap`, but a card is a `WaferMapDisplayItem` carrying no
45
+ `dataCoverage`/`viewport`/`legendBox`/`binLegendRows`/`reticleConfig`, and it
46
+ used to be passed via `item as WaferMapResult`. That cast asserted fields
47
+ which were absent at runtime; reading one unguarded threw, and each such read
48
+ had to be found by hitting it. `collectWarnings` likewise now types
49
+ `result.warnings` as optional, which its body already handled.
50
+
51
+ - **Support for dies/wafers with no reported X/Y position.** Real-world data
52
+ sometimes has no spatial layout at all (wafer-number-only test logs), or a
53
+ lot/wafer where some dies report a position and others don't — previously
54
+ such a die was silently dropped by every parser upstream of this library, and
55
+ wmap itself had no representation for "no position" beyond crashing or
56
+ drawing garbage.
57
+ - `hasPosition(die)` / `isPositionedDie(die)` (`packages/core/dies.ts`) are
58
+ the predicates every spatial code path filters on — the first narrows
59
+ `x`/`y`, the second narrows `physX`/`physY` with them and so yields a
60
+ `PositionedDie`. `getDieKey` falls back to `` `id:${id}` `` for an
61
+ unpositioned die so two of them never collide.
62
+ - `buildWaferMap` partitions positioned/unpositioned dies before geometry
63
+ inference runs; unpositioned dies are folded back into the returned
64
+ `dies`/`dataCoverage`/`yield` afterward. `dataCoverage` gained
65
+ `unpositionedDies: number` (always present); `totalDies`/`filledDies`/
66
+ `ratio` stay scoped to positioned dies only. Yield is deliberately
67
+ **not** spatial — a coordinate-less die with bin data still counts toward
68
+ it, same as `isYieldEligibleDie` always ignored position.
69
+ - `analyzeWaferMap`/`regions.ts` exclude unpositioned dies from spatial
70
+ region families only (ring/quadrant/sector/reticle-position, cluster and
71
+ pattern detection). Everything value-based (yield, bin counts, per-test
72
+ stats, spec-limit yield) is unaffected — coordinate-less dies still count.
73
+ - A wafer with **zero** positioned dies never renders as a wafer-shaped
74
+ visual — a fabricated mosaic risks being misread as real spatial data.
75
+ `renderWaferMap`/`renderWaferGallery` instead show a compact,
76
+ plot-mode-aware summary in its place: a bin breakdown (reusing the
77
+ existing summary-panel component, so colours match the map's own bin
78
+ legend) for hard/soft-bin modes, or a small histogram for value mode
79
+ (coloured through the same colour-scheme/log-scale/spec-range resolution
80
+ the map's own colorbar uses). A **View die list** toggle reaches the full
81
+ per-die table (`buildDieListSection`, new, exported from `/render`) for
82
+ CSV export and per-die inspection, and **View chart** returns. A mixed
83
+ wafer keeps its normal map for the positioned dies, plus an expandable
84
+ "+N dies without position data" footer giving the same chart/die-list
85
+ toggle for the rest.
86
+ - The toolbar's spatial-only controls (zoom/pan/select/download,
87
+ orientation, overlays, legend position) are hidden on a fully
88
+ coordinate-less card — nothing to act on with no map drawn. Plot mode and
89
+ colour scheme stay, since both drive the summary's own appearance.
90
+ - A gallery card's own "expand" button now preserves that card's live view
91
+ state (plot mode, active test, colour scheme, …) when opening the
92
+ detached window/popup, rather than always reverting to the gallery's
93
+ shared default — a pre-existing gap for any card (not specific to
94
+ coordinate-less data) that became far more visible once a card's
95
+ representation could change shape between modes (bin breakdown vs.
96
+ histogram vs. map).
97
+
98
+ ### Fixed
99
+
100
+ - **`buildDieListSection`'s table was capped at a fixed 360px**, regardless of
101
+ how much room its container actually had — a short table floated in a
102
+ half-empty card, and a host mounting it inside a scrolling container of its
103
+ own got two nested scrollbars. It now fills its container by default
104
+ (`flex: 1; min-height: 0`); a new optional `maxHeight` re-imposes a cap for a
105
+ host with no definite-height container to fill.
106
+
107
+ ### Known gaps (not fixed this pass, tracked for a follow-up)
108
+
109
+ - Stacked plot modes (`stackedValues`/`stackedBins`/`stackedSoftBins`) discard
110
+ every individual wafer card from the gallery grid and pool across the lot —
111
+ a coordinate-less wafer's dies are correctly excluded from those pooled
112
+ aggregates (stacking is inherently position-based), but nothing in the UI
113
+ currently indicates a wafer was excluded; it's indistinguishable from that
114
+ wafer not existing in the lot at all.
115
+ - `hasPosition`, `PositionedDie`, `dataCoverage.unpositionedDies`, and
116
+ `buildDieListSection`/`DieListOptions` are documented in `docs/api.md`
117
+ (§12.1, §7); a dedicated numbered reference entry for `hasPosition` and
118
+ `buildDieListSection` alongside the library's other numbered API entries
119
+ has not been added yet.
120
+
121
+ ---
122
+
123
+ ## [0.22.0] — 2026-08-04
124
+
125
+ ### Breaking
126
+
127
+ - **`StatsSummary.stats.warnings` is now `WaferWarning[]`, not `string[]`.** The
128
+ library raised advisories in two incompatible shapes — structured
129
+ `{ code, message }` on `WaferMapResult.warnings`, raw prose strings on the stats
130
+ summary — so a host had two vocabularies to handle and no stable key to branch
131
+ on for half of them. Both are now `WaferWarning`.
132
+
133
+ Migration: read `w.message` where you previously read the string, and branch on
134
+ the new stable `w.code` (`'test-count-capped'` is the one raised today) instead
135
+ of matching prose. Code that did `warnings[0].includes('…')` is exactly what
136
+ this replaces — that string was never a contract.
137
+
138
+ - **`findTestDef`, `resolveTestNumber`, `getUniqueTestNumbers` and
139
+ `generateTextOverlay` are no longer exported from `/renderer` (or the root).**
140
+ They are internal helpers of the view pipeline with no documented contract, and
141
+ nothing outside the library used them. An export nobody can look up is still API
142
+ surface you cannot change later, so they were withdrawn rather than documented.
143
+
144
+ Migration: none expected. If you did depend on one, import it from
145
+ `@wafertools/wafermap/renderer/buildView.js` and open an issue saying what for —
146
+ the fix is to give you a supported entry point, not to re-widen the surface.
147
+
148
+ ### Added
149
+
150
+ - **The library now surfaces its own data warnings.** A ⚠ indicator appears in the
151
+ toolbar only when there is something to say; clicking it lists each advisory with
152
+ its code and explanation. It also feeds the Summary panel's banner and a new
153
+ `onWarning` callback, all from one collected, de-duplicated, severity-ordered set.
154
+
155
+ This closes a real gap rather than adding a nicety: **geometry advisories were
156
+ rendered by no UI at all.** `'partial-coverage'` means the inferred diameter and
157
+ centre may be wrong and dies may be drawn in the wrong place, and nothing ever
158
+ told the person looking at the map. Analysis advisories fared little better — they
159
+ appeared only if the host both passed `statsSummary` and the user opened the
160
+ Summary panel. The library has the information to know the display may mislead,
161
+ so showing it is its responsibility, not the caller's.
162
+
163
+ Deliberately not a toast: these are persistent conditions about whether the map
164
+ can be trusted, and a message that dismisses itself leaves the map still wrong
165
+ with no way back to the explanation.
166
+
167
+ - **`WarningsOptions` on `renderWaferMap` and `renderWaferGallery`** —
168
+ `{ display?: boolean; onWarning?: (warnings: WaferWarning[]) => void }`. Hosts
169
+ with their own notification system pass `{ display: false, onWarning }`: the
170
+ library still collects, de-duplicates and severity-orders, and the host owns only
171
+ presentation. `collectWarnings` and `severityOf` are exported from
172
+ `@wafertools/wafermap/render` so such a host can reproduce exactly the set the
173
+ built-in UI would have shown rather than re-deriving it from two sources.
174
+
175
+ - **`WaferWarning.severity`** — `'error' | 'warning' | 'info'`, defaulting to
176
+ `'warning'`. Geometry advisories are `'error'` (the map may be positionally
177
+ wrong); the test-count cap is `'warning'` (a feature produced nothing, but what
178
+ is drawn is correct). Drives the indicator's colour and ordering — and the
179
+ severity is always in the accessible name too, never colour alone.
180
+
181
+ - New `--wmap-err-bg` / `--wmap-err-border` / `--wmap-err-text` theme tokens
182
+ (9.55:1 contrast on their background, clearing WCAG AA on all three surfaces
183
+ they appear on).
184
+
185
+
186
+ - **Downloadable examples package.** `site/wafermap-examples.zip`, built as part of
187
+ `npm run build:site` and published alongside the docs. Contains every example, the
188
+ bundled library, the sample datasets, and a `starter/` skeleton to copy as the seed
189
+ of an application. Unzip, run `sh serve.sh` (or `serve.cmd` on Windows), and it works
190
+ with no npm install and no network — the offline path matters for locked-down fab
191
+ networks. The bundled `serve.py` pins the MIME type for `.js` rather than trusting
192
+ the platform: Python's stdlib server reads MIME types from the Windows registry, and
193
+ where that mapping has been altered it serves JavaScript as `text/plain`, which
194
+ browsers refuse to execute as a module — failing every page on Windows while working
195
+ on Linux.
196
+ - **`AGENTS.md` — usage rules for AI coding agents.** Most consumers now write wafer
197
+ map code through Claude Code, Codex, Copilot or Cursor, and this library's inputs
198
+ invite confident wrong guesses: `die.hbin ?? 0` reads as ordinary defensive coding
199
+ but turns no-data dies into bin 0 and moves the yield number; `activeTest` reads
200
+ like an index but is a test number. The file's core is a copy-paste block for the
201
+ consumer's own agent config, surfaced at
202
+ [/agents/](https://wafertools.github.io/wafermap/agents/), shipped in the npm
203
+ package (`node_modules/@wafertools/wafermap/AGENTS.md`) and at the root of the
204
+ examples archive. `scripts/check-agents-guide.mjs` verifies it against
205
+ `dist/**/*.d.ts` on every `npm run check` and CI run — every recommended symbol
206
+ must exist, every symbol in the removal table (parsed from the table itself, not a
207
+ duplicate list) must be absent, and structural claims are checked rather than
208
+ trusted. An agent guide that names a removed API is worse than none.
209
+ - `llms.txt` now ships in the npm package and the examples archive, with its
210
+ repo-relative links replaced by absolute ones — they resolved to nothing in both
211
+ of those locations.
212
+ - `docs/examples/manifest.json` — single source for the examples list, consumed by
213
+ `demo-nav.js`, the `index.md` generator, the archive builder, and a nav consistency
214
+ check wired into `npm run check` and the test suite.
23
215
 
24
216
  ### Changed
25
217
 
218
+ - **Findings no longer restate the same fact several times.** One edge failure
219
+ could produce, per region: a hard-bin row, its soft-bin twin with an identical
220
+ delta, a pass-bin row, and a yield row saying the same thing as the pass-bin row
221
+ — up to seven rows for what an engineer would state in one sentence. Two exact
222
+ redundancies now collapse:
223
+
224
+ - A soft-bin finding whose hard-bin twin covers **provably the same dies** is
225
+ absorbed, and the surviving row says so (`Hard and soft bin 3 (same dies)`).
226
+ The test is die-set identity computed from the dies, never bin-number
227
+ equality — hard and soft bins are independent number spaces, and merging on
228
+ the number would report one population under the other's name.
229
+ - When exactly one pass bin is configured, that bin's row and the yield row are
230
+ the same statement by definition, so the bin row is absorbed into the yield
231
+ row. With several pass bins no single bin equals yield, and the rule correctly
232
+ does not fire.
233
+
234
+ The surviving row names what it absorbed — "Ring 4 (edge) has hard bin 3 and
235
+ soft bin 3 (same dies) occurrence 8.8 percentage points higher…" — rather than
236
+ quietly dropping the other half. "(same dies)" is load-bearing: without it the
237
+ wording could be read as two populations summed.
238
+
239
+ This applies at lot level too. `analyzeWaferLot` skips per-wafer findings that
240
+ were absorbed, so a twin does not reappear as its own lot row — which is where
241
+ the duplication was most misleading, since every lot row is annotated "seen on
242
+ N/M wafers" and one fact stated twice reads as two signals corroborating
243
+ each other.
244
+
245
+ Nothing is discarded: absorbed findings remain in `summary.findings` and are
246
+ still returned by `filterFindings`. Only the Summary panel and the findings
247
+ report hide them, via the new `StatsFinding.absorbedIds`.
248
+
249
+ - `StatsFinding.absorbedIds` — IDs of findings another finding restates. Kept
250
+ separate from `relatedIds`, which already meant two different things (a
251
+ run-merge's audit trail of constituents it *replaced*, which no longer exist,
252
+ and a spatial pattern's live supporting detail). Anything in `absorbedIds` is
253
+ guaranteed still present in `findings`.
254
+
255
+ - **Examples consolidated from 26 pages to 20.** Several demos differed only by which
256
+ option was enabled. `findings`, `summary-panel`, `lot-findings`, `gallery` and
257
+ `lot-stack-analysis` are now one `statistics.html` with a scope selector
258
+ (single wafer / lot gallery / lot stack) whose banner names the exact calls and
259
+ options in force; `color-schemes` folded into `display-control.html`, where the
260
+ existing scheme dropdown already did the same job; `partial-data` folded into
261
+ `geometry.html` as a second section. Every old URL keeps a redirect stub pointing at
262
+ the merged page and the anchor that reproduces what it used to show, and the Guide
263
+ cross-links now target those anchors, so per-topic granularity is unchanged.
264
+
26
265
  - **Package renamed from `@paulrobins/wafermap` to `@wafertools/wafermap`.** The GitHub repo
27
266
  moved from `telecasterer/wafermap` to `wafertools/wafermap` along with it — repository,
28
267
  homepage, and issue-tracker URLs all point at the new org. `@paulrobins/wafermap` is
29
268
  deprecated on npm in favour of this package; no functional changes.
30
269
 
270
+ ### Fixed
271
+
272
+ - **The toolbar could overflow across neighbouring content.** It is pinned by its
273
+ right edge with no width bound, so once wider than its container the excess grew
274
+ leftward — out of the card and over whatever sat beside it (the next map in a
275
+ grid, an adjacent gallery card). It had always been wider than a ~400px
276
+ container. It now wraps within the container, breaking between control groups
277
+ rather than mid-group, with `wrap-reverse` so the trailing group keeps the top
278
+ row and Expand keeps its top-right corner.
279
+
280
+
281
+ - **Custom colour schemes did not work on the built documentation site.**
282
+ `scripts/bundle-docs.mjs` built each importmap entry point as an independent esbuild
283
+ bundle, so `wafermap` and `wafermap/render` each inlined a private copy of the
284
+ colour-scheme registry. `registerColorScheme` imported from `wafermap` wrote into a
285
+ registry the renderer never read, and a custom scheme rendered pixel-identical to the
286
+ default palette — silently, with no error. All entry points are now built in one
287
+ invocation with code splitting, so shared module state lives in a common chunk. Only
288
+ ever affected the bundled site build; `npm run dev` serves unbundled modules that
289
+ resolve to one shared file, which is why it went unnoticed.
290
+
31
291
  ## [0.21.1] — 2026-07-31
32
292
 
33
293
  ### Added
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # wafermap
2
2
 
3
+ <img src="docs/images/wafermap-readme-header-256.png" width="64" height="64" alt="wafermap icon">
4
+
3
5
  [![CI](https://github.com/wafertools/wafermap/actions/workflows/ci.yml/badge.svg)](https://github.com/wafertools/wafermap/actions/workflows/ci.yml)
4
6
  [![npm](https://img.shields.io/npm/v/@wafertools/wafermap.svg)](https://www.npmjs.com/package/@wafertools/wafermap)
5
7
  ![runtime deps](https://img.shields.io/badge/runtime%20deps-0-brightgreen)
@@ -14,6 +16,11 @@ Browser-first wafer map visualization for semiconductor test data.
14
16
 
15
17
  **[Project Portal: Docs & Interactive Demos →](https://wafertools.github.io/wafermap/)**
16
18
 
19
+ ## Community
20
+
21
+ Questions, ideas, or want to show off a wafer map you built? Use [GitHub
22
+ Discussions](https://github.com/wafertools/.github/discussions).
23
+
17
24
  ## Overview
18
25
 
19
26
  wafermap renders interactive wafer maps from semiconductor prober output. Hard bins, soft bins, test values, retest runs, edge exclusion, and spec limits are native inputs.
@@ -56,8 +63,9 @@ and stats layers.
56
63
  - [Developer Guide](https://wafertools.github.io/wafermap/guide/)
57
64
  - [SvelteKit](https://wafertools.github.io/wafermap/sveltekit/) · [React](https://wafertools.github.io/wafermap/react/) · [Vue 3](https://wafertools.github.io/wafermap/vue/) integration guides
58
65
  - [API Reference](https://wafertools.github.io/wafermap/api/)
66
+ - [Using wafermap with an AI coding agent](https://wafertools.github.io/wafermap/agents/) — rules to paste into Claude Code / Codex / Copilot / Cursor, also shipped as `AGENTS.md` in this package
59
67
  - [Architecture](https://wafertools.github.io/wafermap/architecture/) · [Performance](https://wafertools.github.io/wafermap/performance/) · [Troubleshooting](https://wafertools.github.io/wafermap/troubleshooting/)
60
- - [Live examples](https://wafertools.github.io/wafermap/examples/)
68
+ - [Live examples](https://wafertools.github.io/wafermap/examples/) — or [download them](https://wafertools.github.io/wafermap/wafermap-examples.zip) to run and edit locally, offline, with the library bundled in
61
69
 
62
70
  **Using an app built with wafermap** (test / device / yield engineers):
63
71
 
@@ -1 +1 @@
1
- import{buildTestHistogramData as Ht,buildTestHistogramSeries as At}from"../../stats/histogram.js";import{CLR as lt}from"../toolbar.js";import{fmt as st}from"../../renderer/fmt.js";import{QUANTITY as Wt,categorical as Ct}from"./palette.js";import{cardShell as Et,observeResize as xt,makeTooltip as Pt,positionChartTooltip as bt,makeTestSelect as $t,makeWaferSelect as Bt,makeToggle as Rt,renderEmptyState as pt,chartFillHeight as yt,applyCanvasFlow as Mt,resolveChartCanvasColors as Tt,makeAxisFormat as vt,PADDING as tt}from"./chartShell.js";const mt=230,St=36,at=18;function kt(s,R,N,z,rt,I,b,u=4){const j=Math.max(1,I/u),F=Math.pow(10,Math.floor(Math.log10(j))),f=j/F,G=(f<=1?1:f<=2?2:f<=5?5:10)*F,$=z-N;s.save(),s.font="10px system-ui, sans-serif",s.textAlign="right",s.textBaseline="middle";for(let K=0;K<=I+1e-9;K+=G){const _=z-K/(I||1)*$;s.strokeStyle=b.grid,s.lineWidth=1,s.beginPath(),s.moveTo(R,_),s.lineTo(R+rt,_),s.stroke(),s.fillStyle=b.text,s.fillText(String(Math.round(K)),R-4,_)}s.strokeStyle=b.axis,s.lineWidth=1,s.beginPath(),s.moveTo(R,N),s.lineTo(R,z),s.stroke(),s.restore()}export function renderHistogramPanel(s){const{title:R="Test value distribution",items:N,testDefs:z,onSaveImage:rt,groups:I}=s,{card:b,body:u,controlsRow:j}=Et(R,rt),F=z.filter(a=>a.testNumber!==void 0);let f=s.selectedTestNumber??F[0]?.testNumber??null,G=null,$=!1;const K=$t(F,f,a=>{f=a,U()},{maxWidth:"200px"});j.appendChild(K);const _=Bt(N,G,a=>{G=a,U()});j.appendChild(_),j.appendChild(Rt("Axis includes limits",$,a=>{$=a,U()}));const A=Pt(b);let J=null,M=null;function et(a){const w=z.find(O=>O.testNumber===a);return{unit:w?.unit,limitLow:w?.limitLow,limitHigh:w?.limitHigh}}function U(){if(J?.disconnect(),J=null,u.innerHTML="",f===null){pt(u,"No parametric test data available for a histogram.");return}const a=I&&I.length>0?At(I,f,16,$?et(f).limitLow:void 0,$?et(f).limitHigh:void 0):null;if(_.style.display=a?"none":"",a){if(a.series.length===0){pt(u,"No parametric test data available for a histogram.");return}Lt(a);return}const w=G!==null?[N[G]]:N,{unit:O,limitLow:nt,limitHigh:x}=et(f),i=Ht(w,f,16,$?nt:void 0,$?x:void 0);if(i.length===0){pt(u,"No parametric test data available for a histogram.");return}const D=Math.max(...i.map(p=>p.count),1),P=document.createElement("div");Object.assign(P.style,{fontSize:"12px",color:lt.label,marginBottom:"2px"}),P.textContent=`max ${D} dies/bucket`,u.appendChild(P);const h=document.createElement("canvas");h.style.display="block",h.style.cursor="default",u.appendChild(h);let v=-1;const g=window.devicePixelRatio||1,B=i[0].rangeLow,X=i[i.length-1].rangeHigh,ot=X-B||1,Z=vt(Math.max(Math.abs(B),Math.abs(X)),O);function Y(p){const m=tt+36,e=h.clientWidth-m-tt,n=p-St-at;return{plotX:m,plotMaxWidth:Math.max(10,e),plotMaxHeight:n,plotTop:at}}function ct(p){const{plotX:m,plotMaxWidth:e}=Y(mt);if(p<m||p>m+e)return-1;const n=Math.floor((p-m)/e*i.length);return n>=0&&n<i.length?n:-1}function H(){Mt(h,P.offsetHeight);const p=u.clientWidth,m=yt(b,u,h,mt);h.width=Math.max(1,Math.floor(p*g)),h.height=Math.max(1,Math.floor(m*g)),h.style.width=`${p}px`,h.style.height=`${m}px`;const e=h.getContext("2d");e.setTransform(g,0,0,g,0,0),e.clearRect(0,0,p,m),e.font="11px system-ui, sans-serif";const n=Tt(b),{plotX:c,plotMaxWidth:t,plotMaxHeight:d,plotTop:o}=Y(m),l=o+d,S=t/i.length;kt(e,c,o,l,t,D,{text:n.textMuted,axis:n.border,grid:n.border}),i.forEach((y,C)=>{const k=y.count/D*d,r=c+C*S,L=l-k;e.fillStyle=C===v?n.text:Wt,e.globalAlpha=C===v?1:.7,e.fillRect(r+1,L,Math.max(1,S-2),k),e.globalAlpha=1,C===v&&(e.fillStyle=n.text,e.textAlign="center",e.textBaseline="bottom",e.fillText(`${y.count}`,r+S/2,L-2))});const V=y=>c+(y-B)/ot*t;e.font="10px system-ui, sans-serif";for(const[y,C]of[[nt,"LSL"],[x,"USL"]]){if(y===void 0)continue;const k=V(y);e.strokeStyle=n.warnBorder,e.setLineDash([3,3]),e.lineWidth=1,e.beginPath(),e.moveTo(k,o),e.lineTo(k,l),e.stroke(),e.setLineDash([]),e.fillStyle=n.warnBorder,e.textAlign="center",e.textBaseline="bottom",e.fillText(C,k,o-2)}e.font="11px system-ui, sans-serif",e.strokeStyle=n.border,e.lineWidth=1,e.beginPath(),e.moveTo(c,l),e.lineTo(c+t,l),e.stroke(),e.fillStyle=n.textMuted,e.textAlign="center",e.textBaseline="top";const W=Math.max(2,Math.floor(t/44)),dt=Math.ceil(i.length/W),ht=Math.max(1,dt);for(let y=0;y<=i.length;y+=ht){const C=y<i.length?i[y].rangeLow:i[i.length-1].rangeHigh,k=c+y*S;e.beginPath(),e.moveTo(k,l),e.lineTo(k,l+4),e.stroke(),e.fillText(Z.tick(C),k,l+6)}Z.unitLabel&&(e.save(),e.font="10px system-ui, sans-serif",e.fillStyle=n.textMuted,e.textAlign="right",e.textBaseline="top",e.fillText(`(${Z.unitLabel})`,c+t,l+20),e.restore())}h.addEventListener("mousemove",p=>{const m=h.getBoundingClientRect(),e=ct(p.clientX-m.left);if(e!==v&&(v=e,H()),e>=0){const n=i[e];A.innerHTML=`<strong>${st(n.rangeLow,O,"engineering")} \u2013 ${st(n.rangeHigh,O,"engineering")}</strong><br>${n.count} dies`,A.style.display="block",bt(A,b,p.clientX,p.clientY)}else A.style.display="none"}),h.addEventListener("mouseleave",()=>{v!==-1&&(v=-1,H()),A.style.display="none"}),J=xt(b,()=>H()),H()}function Lt(a){const{unit:w,limitLow:O,limitHigh:nt}=et(f),x=a.ranges,i=a.series,D=Ct;M&&!i.some(n=>n.groupKey===M)&&(M=null);const P=Math.max(1,...i.flatMap(n=>n.counts)),h=document.createElement("div");Object.assign(h.style,{fontSize:"12px",color:lt.label,marginBottom:"2px"}),h.textContent=`${i.length} groups \xB7 max ${P} dies/bucket`,u.appendChild(h);const v=document.createElement("div");Object.assign(v.style,{display:"flex",flexWrap:"wrap",gap:"4px 12px",marginBottom:"4px"}),i.forEach((n,c)=>{const t=document.createElement("button");t.type="button",t.title=`${n.groupKey} \u2014 click to emphasize (dim the rest)`,Object.assign(t.style,{display:"inline-flex",alignItems:"center",gap:"5px",fontSize:"11px",padding:"1px 4px",border:"none",background:"none",cursor:"pointer",color:lt.text,borderRadius:"3px"});const d=document.createElement("span");Object.assign(d.style,{width:"10px",height:"10px",borderRadius:"2px",background:D(c),flex:"0 0 auto"});const o=document.createElement("span");o.textContent=n.groupKey,t.append(d,o);const l=M!==null&&M!==n.groupKey;t.style.opacity=l?"0.45":"1",M===n.groupKey&&(t.style.background=lt.bgHover),t.addEventListener("click",()=>{M=M===n.groupKey?null:n.groupKey,U()}),v.appendChild(t)}),u.appendChild(v);const g=document.createElement("canvas");g.style.display="block",g.style.cursor="default",u.appendChild(g);const B=window.devicePixelRatio||1,X=x[0].rangeLow,ot=x[x.length-1].rangeHigh,Z=ot-X||1,Y=vt(Math.max(Math.abs(X),Math.abs(ot)),w),ct=()=>h.offsetHeight+v.offsetHeight;let H=-1,p={plotX:tt+36,plotMaxWidth:10,barWidth:10};function m(){Mt(g,ct());const n=u.clientWidth,c=yt(b,u,g,mt);g.width=Math.max(1,Math.floor(n*B)),g.height=Math.max(1,Math.floor(c*B)),g.style.width=`${n}px`,g.style.height=`${c}px`;const t=g.getContext("2d");t.setTransform(B,0,0,B,0,0),t.clearRect(0,0,n,c),t.font="11px system-ui, sans-serif";const d=Tt(b),o=tt+36,l=Math.max(10,n-o-tt),S=at,V=c-St-at,T=S+V,W=l/x.length;p={plotX:o,plotMaxWidth:l,barWidth:W},kt(t,o,S,T,l,P,{text:d.textMuted,axis:d.border,grid:d.border}),H>=0&&(t.fillStyle=d.bgHover,t.globalAlpha=.5,t.fillRect(o+H*W,S,W,V),t.globalAlpha=1);const dt=i.map((r,L)=>({s:r,i:L})).sort((r,L)=>(r.s.groupKey===M?1:0)-(L.s.groupKey===M?1:0));for(const{s:r,i:L}of dt){const E=M===r.groupKey,ut=M!==null&&!E,ft=D(L);t.strokeStyle=ft,t.fillStyle=ft,t.lineWidth=E?2:1,t.globalAlpha=ut?.12:M?.32:.22,t.beginPath(),t.moveTo(o,T),r.counts.forEach((gt,it)=>{const Q=o+it*W,q=T-gt/P*V;t.lineTo(Q,q),t.lineTo(Q+W,q)}),t.lineTo(o+l,T),t.closePath(),t.fill(),t.globalAlpha=ut?.3:1,t.beginPath(),r.counts.forEach((gt,it)=>{const Q=o+it*W,q=T-gt/P*V;it===0?t.moveTo(Q,q):t.lineTo(Q,q),t.lineTo(Q+W,q)}),t.stroke()}t.globalAlpha=1;const ht=r=>o+(r-X)/Z*l;t.font="10px system-ui, sans-serif";for(const[r,L]of[[O,"LSL"],[nt,"USL"]]){if(r===void 0)continue;const E=ht(r);t.strokeStyle=d.warnBorder,t.setLineDash([3,3]),t.lineWidth=1,t.beginPath(),t.moveTo(E,S),t.lineTo(E,T),t.stroke(),t.setLineDash([]),t.fillStyle=d.warnBorder,t.textAlign="center",t.textBaseline="bottom",t.fillText(L,E,S-2)}t.font="11px system-ui, sans-serif",t.strokeStyle=d.border,t.lineWidth=1,t.beginPath(),t.moveTo(o,T),t.lineTo(o+l,T),t.stroke(),t.fillStyle=d.textMuted,t.textAlign="center",t.textBaseline="top";const C=Math.max(2,Math.floor(l/44)),k=Math.max(1,Math.ceil(x.length/C));for(let r=0;r<=x.length;r+=k){const L=r<x.length?x[r].rangeLow:x[x.length-1].rangeHigh,E=o+r*W;t.beginPath(),t.moveTo(E,T),t.lineTo(E,T+4),t.stroke(),t.fillText(Y.tick(L),E,T+6)}Y.unitLabel&&(t.save(),t.font="10px system-ui, sans-serif",t.fillStyle=d.textMuted,t.textAlign="right",t.textBaseline="top",t.fillText(`(${Y.unitLabel})`,o+l,T+20),t.restore())}function e(n){const{plotX:c,plotMaxWidth:t,barWidth:d}=p;if(n<c||n>c+t)return-1;const o=Math.floor((n-c)/d);return o>=0&&o<x.length?o:-1}g.addEventListener("mousemove",n=>{const c=g.getBoundingClientRect(),t=e(n.clientX-c.left);if(t!==H&&(H=t,m()),t>=0){const d=x[t],o=i.map((l,S)=>`<span style="display:inline-block;width:9px;height:9px;border-radius:2px;background:${D(S)};margin-right:5px;"></span>${l.groupKey}: ${l.counts[t]}`).join("<br>");A.innerHTML=`<strong>${st(d.rangeLow,w,"engineering")} \u2013 ${st(d.rangeHigh,w,"engineering")}</strong><br>${o}`,A.style.display="block",bt(A,b,n.clientX,n.clientY)}else A.style.display="none"}),g.addEventListener("mouseleave",()=>{H!==-1&&(H=-1,m()),A.style.display="none"}),J=xt(b,()=>m()),m()}U();function wt(a){!F.some(w=>w.testNumber===a)||a===f||(f=a,K.value=String(a),U())}return{card:b,setTest:wt,destroy:()=>J?.disconnect()}}
1
+ import{buildTestHistogramData as Ht,buildTestHistogramSeries as At}from"../../stats/histogram.js";import{CLR as lt}from"../toolbar.js";import{fmt as st}from"../../renderer/fmt.js";import{QUANTITY as Wt,categorical as Ct}from"./palette.js";import{cardShell as Et,observeResize as xt,makeTooltip as Pt,positionChartTooltip as bt,makeTestSelect as $t,makeWaferSelect as Bt,makeToggle as Rt,renderEmptyState as pt,chartFillHeight as yt,applyCanvasFlow as Mt,resolveChartCanvasColors as Tt,makeAxisFormat as vt,PADDING as tt}from"./chartShell.js";const mt=230,St=36,at=18;function kt(s,R,N,z,rt,I,b,u=4){const j=Math.max(1,I/u),F=Math.pow(10,Math.floor(Math.log10(j))),f=j/F,G=(f<=1?1:f<=2?2:f<=5?5:10)*F,$=z-N;s.save(),s.font="10px system-ui, sans-serif",s.textAlign="right",s.textBaseline="middle";for(let K=0;K<=I+1e-9;K+=G){const _=z-K/(I||1)*$;s.strokeStyle=b.grid,s.lineWidth=1,s.beginPath(),s.moveTo(R,_),s.lineTo(R+rt,_),s.stroke(),s.fillStyle=b.text,s.fillText(String(Math.round(K)),R-4,_)}s.strokeStyle=b.axis,s.lineWidth=1,s.beginPath(),s.moveTo(R,N),s.lineTo(R,z),s.stroke(),s.restore()}export function renderHistogramPanel(s){const{title:R="Test value distribution",items:N,testDefs:z,onSaveImage:rt,groups:I}=s,{card:b,body:u,controlsRow:j}=Et(R,rt),F=z.filter(a=>a.testNumber!==void 0);let f=s.selectedTestNumber??F[0]?.testNumber??null,G=null,$=!1;const K=$t(F,f,a=>{f=a,U()},{maxWidth:"200px"});j.appendChild(K);const _=Bt(N,G,a=>{G=a,U()});j.appendChild(_),j.appendChild(Rt("Axis includes limits",$,a=>{$=a,U()}));const A=Pt(b);let J=null,y=null;function et(a){const w=z.find(O=>O.testNumber===a);return{unit:w?.unit,limitLow:w?.limitLow,limitHigh:w?.limitHigh}}function U(){if(J?.disconnect(),J=null,u.innerHTML="",f===null){pt(u,"No parametric test data available for a histogram.");return}const a=I&&I.length>0?At(I,f,16,$?et(f).limitLow:void 0,$?et(f).limitHigh:void 0):null;if(_.style.display=a?"none":"",a){if(a.series.length===0){pt(u,"No parametric test data available for a histogram.");return}Lt(a);return}const w=G!==null?[N[G]]:N,{unit:O,limitLow:nt,limitHigh:x}=et(f),i=Ht(w,f,16,$?nt:void 0,$?x:void 0);if(i.length===0){pt(u,"No parametric test data available for a histogram.");return}const D=Math.max(...i.map(p=>p.count),1),P=document.createElement("div");Object.assign(P.style,{fontSize:"12px",color:lt.label,marginBottom:"2px"}),P.textContent=`max ${D} dies/bucket`,u.appendChild(P);const h=document.createElement("canvas");h.style.display="block",h.style.cursor="default",u.appendChild(h);let v=-1;const g=window.devicePixelRatio||1,B=i[0].rangeLow,X=i[i.length-1].rangeHigh,ot=X-B||1,Z=vt(Math.max(Math.abs(B),Math.abs(X)),O);function Y(p){const m=tt+36,e=h.clientWidth-m-tt,n=p-St-at;return{plotX:m,plotMaxWidth:Math.max(10,e),plotMaxHeight:n,plotTop:at}}function ct(p){const{plotX:m,plotMaxWidth:e}=Y(mt);if(p<m||p>m+e)return-1;const n=Math.floor((p-m)/e*i.length);return n>=0&&n<i.length?n:-1}function H(){Mt(h,P.offsetHeight);const p=u.clientWidth,m=yt(b,u,h,mt);h.width=Math.max(1,Math.floor(p*g)),h.height=Math.max(1,Math.floor(m*g)),h.style.width=`${p}px`,h.style.height=`${m}px`;const e=h.getContext("2d");e.setTransform(g,0,0,g,0,0),e.clearRect(0,0,p,m),e.font="11px system-ui, sans-serif";const n=Tt(b),{plotX:c,plotMaxWidth:t,plotMaxHeight:d,plotTop:o}=Y(m),l=o+d,S=t/i.length;kt(e,c,o,l,t,D,{text:n.textMuted,axis:n.border,grid:n.border}),i.forEach((M,C)=>{const k=M.count/D*d,r=c+C*S,L=l-k;e.fillStyle=C===v?n.text:Wt,e.globalAlpha=C===v?1:.7,e.fillRect(r+1,L,Math.max(1,S-2),k),e.globalAlpha=1,C===v&&(e.fillStyle=n.text,e.textAlign="center",e.textBaseline="bottom",e.fillText(`${M.count}`,r+S/2,L-2))});const V=M=>c+(M-B)/ot*t;e.font="10px system-ui, sans-serif";for(const[M,C]of[[nt,"LSL"],[x,"USL"]]){if(M===void 0)continue;const k=V(M);e.strokeStyle=n.warnBorder,e.setLineDash([3,3]),e.lineWidth=1,e.beginPath(),e.moveTo(k,o),e.lineTo(k,l),e.stroke(),e.setLineDash([]),e.fillStyle=n.warnBorder,e.textAlign="center",e.textBaseline="bottom",e.fillText(C,k,o-2)}e.font="11px system-ui, sans-serif",e.strokeStyle=n.border,e.lineWidth=1,e.beginPath(),e.moveTo(c,l),e.lineTo(c+t,l),e.stroke(),e.fillStyle=n.textMuted,e.textAlign="center",e.textBaseline="top";const W=Math.max(2,Math.floor(t/44)),dt=Math.ceil(i.length/W),ht=Math.max(1,dt);for(let M=0;M<=i.length;M+=ht){const C=M<i.length?i[M].rangeLow:i[i.length-1].rangeHigh,k=c+M*S;e.beginPath(),e.moveTo(k,l),e.lineTo(k,l+4),e.stroke(),e.fillText(Z.tick(C),k,l+6)}Z.unitLabel&&(e.save(),e.font="10px system-ui, sans-serif",e.fillStyle=n.textMuted,e.textAlign="right",e.textBaseline="top",e.fillText(`(${Z.unitLabel})`,c+t,l+20),e.restore())}h.addEventListener("mousemove",p=>{const m=h.getBoundingClientRect(),e=ct(p.clientX-m.left);if(e!==v&&(v=e,H()),e>=0){const n=i[e];A.innerHTML=`<strong>${st(n.rangeLow,O,"engineering")} \u2013 ${st(n.rangeHigh,O,"engineering")}</strong><br>${n.count} dies`,A.style.display="block",bt(A,b,p.clientX,p.clientY)}else A.style.display="none"}),h.addEventListener("mouseleave",()=>{v!==-1&&(v=-1,H()),A.style.display="none"}),J=xt(b,()=>H()),H()}function Lt(a){const{unit:w,limitLow:O,limitHigh:nt}=et(f),x=a.ranges,i=a.series,D=Ct;y&&!i.some(n=>n.groupKey===y)&&(y=null);const P=Math.max(1,...i.flatMap(n=>n.counts)),h=document.createElement("div");Object.assign(h.style,{fontSize:"12px",color:lt.label,marginBottom:"2px"}),h.textContent=`${i.length} groups \xB7 max ${P} dies/bucket`,u.appendChild(h);const v=document.createElement("div");Object.assign(v.style,{display:"flex",flexWrap:"wrap",gap:"4px 12px",marginBottom:"4px"}),i.forEach((n,c)=>{const t=document.createElement("button");t.type="button",t.title=`${n.groupKey} \u2014 click to emphasize (dim the rest)`,Object.assign(t.style,{display:"inline-flex",alignItems:"center",gap:"5px",fontSize:"11px",padding:"1px 4px",border:"none",background:"none",cursor:"pointer",color:lt.text,borderRadius:"3px"});const d=document.createElement("span");Object.assign(d.style,{width:"10px",height:"10px",borderRadius:"2px",background:D(c),flex:"0 0 auto"});const o=document.createElement("span");o.textContent=n.groupKey,t.append(d,o);const l=y!==null&&y!==n.groupKey;t.style.opacity=l?"0.45":"1",y===n.groupKey&&(t.style.background=lt.bgHover),t.setAttribute("aria-pressed",y===n.groupKey?"true":"false"),t.addEventListener("click",()=>{y=y===n.groupKey?null:n.groupKey,U()}),v.appendChild(t)}),u.appendChild(v);const g=document.createElement("canvas");g.style.display="block",g.style.cursor="default",u.appendChild(g);const B=window.devicePixelRatio||1,X=x[0].rangeLow,ot=x[x.length-1].rangeHigh,Z=ot-X||1,Y=vt(Math.max(Math.abs(X),Math.abs(ot)),w),ct=()=>h.offsetHeight+v.offsetHeight;let H=-1,p={plotX:tt+36,plotMaxWidth:10,barWidth:10};function m(){Mt(g,ct());const n=u.clientWidth,c=yt(b,u,g,mt);g.width=Math.max(1,Math.floor(n*B)),g.height=Math.max(1,Math.floor(c*B)),g.style.width=`${n}px`,g.style.height=`${c}px`;const t=g.getContext("2d");t.setTransform(B,0,0,B,0,0),t.clearRect(0,0,n,c),t.font="11px system-ui, sans-serif";const d=Tt(b),o=tt+36,l=Math.max(10,n-o-tt),S=at,V=c-St-at,T=S+V,W=l/x.length;p={plotX:o,plotMaxWidth:l,barWidth:W},kt(t,o,S,T,l,P,{text:d.textMuted,axis:d.border,grid:d.border}),H>=0&&(t.fillStyle=d.bgHover,t.globalAlpha=.5,t.fillRect(o+H*W,S,W,V),t.globalAlpha=1);const dt=i.map((r,L)=>({s:r,i:L})).sort((r,L)=>(r.s.groupKey===y?1:0)-(L.s.groupKey===y?1:0));for(const{s:r,i:L}of dt){const E=y===r.groupKey,ut=y!==null&&!E,ft=D(L);t.strokeStyle=ft,t.fillStyle=ft,t.lineWidth=E?2:1,t.globalAlpha=ut?.12:y?.32:.22,t.beginPath(),t.moveTo(o,T),r.counts.forEach((gt,it)=>{const Q=o+it*W,q=T-gt/P*V;t.lineTo(Q,q),t.lineTo(Q+W,q)}),t.lineTo(o+l,T),t.closePath(),t.fill(),t.globalAlpha=ut?.3:1,t.beginPath(),r.counts.forEach((gt,it)=>{const Q=o+it*W,q=T-gt/P*V;it===0?t.moveTo(Q,q):t.lineTo(Q,q),t.lineTo(Q+W,q)}),t.stroke()}t.globalAlpha=1;const ht=r=>o+(r-X)/Z*l;t.font="10px system-ui, sans-serif";for(const[r,L]of[[O,"LSL"],[nt,"USL"]]){if(r===void 0)continue;const E=ht(r);t.strokeStyle=d.warnBorder,t.setLineDash([3,3]),t.lineWidth=1,t.beginPath(),t.moveTo(E,S),t.lineTo(E,T),t.stroke(),t.setLineDash([]),t.fillStyle=d.warnBorder,t.textAlign="center",t.textBaseline="bottom",t.fillText(L,E,S-2)}t.font="11px system-ui, sans-serif",t.strokeStyle=d.border,t.lineWidth=1,t.beginPath(),t.moveTo(o,T),t.lineTo(o+l,T),t.stroke(),t.fillStyle=d.textMuted,t.textAlign="center",t.textBaseline="top";const C=Math.max(2,Math.floor(l/44)),k=Math.max(1,Math.ceil(x.length/C));for(let r=0;r<=x.length;r+=k){const L=r<x.length?x[r].rangeLow:x[x.length-1].rangeHigh,E=o+r*W;t.beginPath(),t.moveTo(E,T),t.lineTo(E,T+4),t.stroke(),t.fillText(Y.tick(L),E,T+6)}Y.unitLabel&&(t.save(),t.font="10px system-ui, sans-serif",t.fillStyle=d.textMuted,t.textAlign="right",t.textBaseline="top",t.fillText(`(${Y.unitLabel})`,o+l,T+20),t.restore())}function e(n){const{plotX:c,plotMaxWidth:t,barWidth:d}=p;if(n<c||n>c+t)return-1;const o=Math.floor((n-c)/d);return o>=0&&o<x.length?o:-1}g.addEventListener("mousemove",n=>{const c=g.getBoundingClientRect(),t=e(n.clientX-c.left);if(t!==H&&(H=t,m()),t>=0){const d=x[t],o=i.map((l,S)=>`<span style="display:inline-block;width:9px;height:9px;border-radius:2px;background:${D(S)};margin-right:5px;"></span>${l.groupKey}: ${l.counts[t]}`).join("<br>");A.innerHTML=`<strong>${st(d.rangeLow,w,"engineering")} \u2013 ${st(d.rangeHigh,w,"engineering")}</strong><br>${o}`,A.style.display="block",bt(A,b,n.clientX,n.clientY)}else A.style.display="none"}),g.addEventListener("mouseleave",()=>{H!==-1&&(H=-1,m()),A.style.display="none"}),J=xt(b,()=>m()),m()}U();function wt(a){!F.some(w=>w.testNumber===a)||a===f||(f=a,K.value=String(a),U())}return{card:b,setTest:wt,destroy:()=>J?.disconnect()}}
@@ -1 +1 @@
1
- import{getColorScheme as ht}from"../../renderer/colorSchemes.js";import{categorical as gt}from"./palette.js";import{buildScatterData as bt,buildScatterDataGrouped as yt}from"../../stats/scatter.js";import{buildFacetTable as St}from"../../stats/facets.js";import{CLR as M}from"../toolbar.js";import{cardShell as Mt,observeResize as Tt,makeTooltip as wt,makeTestSelect as kt,makeWaferSelect as vt,chartFillHeight as Lt,applyCanvasFlow as Ct,drawAxisUnit as At,resolveChartCanvasColors as Bt,makeAxisFormat as nt}from"./chartShell.js";const s=52,Ot=16,i=16,Et=44;export function renderScatterPanel(j){const{title:ot="Test scatter",items:B,testDefs:_,colorScheme:it="default",onSaveImage:lt,groups:P}=j,{card:T,body:C,controlsRow:X}=Mt(ot,lt),R=_.filter(e=>e.testNumber!==void 0),b=!!(P&&P.length>0),Y=(P??[]).map(e=>e.key),at=new Map(Y.map((e,n)=>[e,n]));let m=j.xTestNumber??R[0]?.testNumber??null,f=j.yTestNumber??R[1]?.testNumber??m,O=null;const q=()=>O!==null?B[O]?[B[O]]:[]:B;function K(e,n,o){const c=document.createElement("label");Object.assign(c.style,{display:"inline-flex",alignItems:"center",gap:"4px",fontSize:"11px",color:M.label});const d=document.createElement("span");d.textContent=e;const p=kt(R,n,o,{maxWidth:"180px",emptyText:"No tests"});return c.append(d,p),{wrap:c,select:p}}const{wrap:st,select:rt}=K("X:",m,e=>{m=e,H()}),{wrap:ct,select:dt}=K("Y:",f,e=>{f=e,H()});X.append(st,ct),!b&&B.length>1&&X.appendChild(vt(B,O,e=>{O=e,H()}));const E=document.createElement("div");Object.assign(E.style,{color:M.warnText,background:M.warnBg,border:`1px solid ${M.warnBorder}`,borderRadius:"4px",padding:"4px 8px",fontSize:"11px",marginBottom:"4px",display:"none"}),T.insertBefore(E,C);function J(){const e=b?[]:St(q(),{facetableOnly:!0}).filter(n=>n.splittable).map(n=>n.label);e.length>0?(E.textContent=`\u26A0 Mixed ${e.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.`,E.style.display=""):E.style.display="none"}J();const D=document.createElement("div");D.textContent=b?"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",Object.assign(D.style,{color:M.label,fontSize:"11px",marginBottom:"4px"}),T.insertBefore(D,C);const pt=ht(it),{forBin:mt}=pt,G=e=>b?e.group??"\u2014":String(e.hbin??0),Q=e=>b?gt(at.get(e)??0):mt(Number(e)),V=e=>b?e:e==="0"?"No bin data":`Bin ${e}`,g=new Set,A=document.createElement("div");Object.assign(A.style,{display:"flex",flexWrap:"wrap",gap:"4px",marginBottom:"6px"}),C.appendChild(A);const u=document.createElement("canvas");u.style.display="block",u.style.cursor="crosshair",C.appendChild(u);const F=window.devicePixelRatio||1,Ht=wt(T);let h=[],w=0,I=1,k=0,N=1;function $(e){const n=_.find(o=>o.testNumber===e);return{unit:n?.unit,limitLow:n?.limitLow,limitHigh:n?.limitHigh}}function xt(){const e=C.clientWidth,n=Math.max(200,Math.min(400,e*.65)),o=Lt(T,C,u,n);return{w:e,h:o,plotW:Math.max(10,e-s-Ot),plotH:Math.max(10,o-i-Et)}}function Z(){for(const e of A.querySelectorAll("[data-cat]")){const n=e.dataset.cat,o=g.size===0||g.has(n);e.style.opacity=o?"1":"0.35",e.style.boxShadow=g.has(n)?`0 0 0 2px ${M.text}`:"none"}}function tt(e){A.innerHTML="",g.clear();for(const n of e){const o=document.createElement("button");o.type="button",o.dataset.cat=n,o.title=`${V(n)} \u2014 click to filter`;const c=Q(n);Object.assign(o.style,{display:"inline-flex",alignItems:"center",gap:"4px",padding:"2px 7px",borderRadius:"10px",border:`1px solid ${M.menuBorder}`,background:"none",cursor:"pointer",fontSize:"11px",color:M.text,whiteSpace:"nowrap"});const d=document.createElement("span");Object.assign(d.style,{display:"inline-block",width:"9px",height:"9px",borderRadius:"50%",background:c,flexShrink:"0"}),o.append(d,document.createTextNode(V(n))),o.addEventListener("click",()=>{g.has(n)?g.delete(n):g.add(n),Z(),z()}),A.appendChild(o)}Z()}function z(){Ct(u,A.offsetHeight);const e=Bt(T),n=I-w,o=N-k,{w:c,h:d,plotW:p,plotH:r}=xt();u.width=Math.max(1,Math.floor(c*F)),u.height=Math.max(1,Math.floor(d*F)),u.style.width=`${c}px`,u.style.height=`${d}px`;const t=u.getContext("2d");if(t.setTransform(F,0,0,F,0,0),t.clearRect(0,0,c,d),h.length===0){t.font="12px system-ui, sans-serif",t.fillStyle=e.textMuted,t.textAlign="center",t.textBaseline="middle",t.fillText("No data \u2014 select two parametric tests with values.",c/2,d/2);return}const y=4,v=nt(Math.max(Math.abs(w),Math.abs(I)),m!==null?$(m).unit:void 0),U=nt(Math.max(Math.abs(k),Math.abs(N)),f!==null?$(f).unit:void 0);t.font="10px system-ui, sans-serif",t.strokeStyle=e.border,t.lineWidth=.5,t.fillStyle=e.textMuted;for(let l=0;l<=y;l++){const S=w+n*l/y,a=s+l/y*p;t.beginPath(),t.moveTo(a,i),t.lineTo(a,i+r),t.stroke(),t.textAlign="center",t.textBaseline="top",t.fillText(v.tick(S),a,i+r+4);const L=k+o*l/y,x=i+(1-l/y)*r;t.beginPath(),t.moveTo(s,x),t.lineTo(s+p,x),t.stroke(),t.textAlign="right",t.textBaseline="middle",t.fillText(U.tick(L),s-4,x)}v.unitLabel&&At(t,v.unitLabel,s+p/2,i+r+24,e.textMuted),U.unitLabel&&(t.save(),t.font="10px system-ui, sans-serif",t.fillStyle=e.textMuted,t.textAlign="center",t.textBaseline="middle",t.translate(6,i+r/2),t.rotate(-Math.PI/2),t.fillText(`(${U.unitLabel})`,0,0),t.restore());const W=g.size===0?h:h.filter(l=>g.has(G(l))),et=W.length>5e3?Math.ceil(W.length/5e3):1;t.globalAlpha=Math.max(.15,Math.min(.7,200/(W.length/et)));for(let l=0;l<W.length;l+=et){const S=W[l],a=s+(S.x-w)/n*p,L=i+(1-(S.y-k)/o)*r;t.beginPath(),t.arc(a,L,2.5,0,Math.PI*2),t.fillStyle=Q(G(S)),t.fill()}if(t.globalAlpha=1,m!==null&&f!==null){const l=$(m),S=$(f);t.strokeStyle=e.textMuted,t.fillStyle=e.textMuted,t.lineWidth=1,t.setLineDash([4,3]),t.font="9px system-ui, sans-serif",t.textBaseline="top";for(const[a,L]of[[l.limitLow,"LSL"],[l.limitHigh,"USL"]]){if(a===void 0||a<w||a>I)continue;const x=s+(a-w)/n*p;t.beginPath(),t.moveTo(x,i),t.lineTo(x,i+r),t.stroke(),t.textAlign="left",t.fillText(L,x+2,i+2)}for(const[a,L]of[[S.limitLow,"LSL"],[S.limitHigh,"USL"]]){if(a===void 0||a<k||a>N)continue;const x=i+(1-(a-k)/o)*r;t.beginPath(),t.moveTo(s,x),t.lineTo(s+p,x),t.stroke(),t.textAlign="left",t.fillText(L,s+3,x+2)}t.setLineDash([])}t.strokeStyle=e.border,t.lineWidth=1,t.beginPath(),t.moveTo(s,i),t.lineTo(s,i+r),t.moveTo(s,i+r),t.lineTo(s+p,i+r),t.stroke()}function H(){if(J(),R.length<2||m===null||f===null){h=[],tt([]),z();return}h=b?yt(P,m,f):bt(q(),m,f);let e;if(b){const n=new Set(h.map(G));e=Y.filter(o=>n.has(o))}else e=Array.from(new Set(h.map(n=>n.hbin??0))).sort((n,o)=>n-o).map(String);if(tt(e),h.length>0){const n=h.map(v=>v.x),o=h.map(v=>v.y),c=Math.min(...n),d=Math.max(...n),p=Math.min(...o),r=Math.max(...o),t=(d-c)*.05||1,y=(r-p)*.05||1;w=c-t,I=d+t,k=p-y,N=r+y}z()}const ft=Tt(T,()=>z());H();function ut(e,n){m=e,f=n,rt.value=String(e),dt.value=String(n),H()}return{card:T,setXY:ut,destroy:()=>ft.disconnect()}}
1
+ import{getColorScheme as ht}from"../../renderer/colorSchemes.js";import{categorical as gt}from"./palette.js";import{buildScatterData as bt,buildScatterDataGrouped as yt}from"../../stats/scatter.js";import{buildFacetTable as St}from"../../stats/facets.js";import{CLR as M}from"../toolbar.js";import{cardShell as Mt,observeResize as Tt,makeTooltip as wt,makeTestSelect as kt,makeWaferSelect as vt,chartFillHeight as Lt,applyCanvasFlow as Ct,drawAxisUnit as At,resolveChartCanvasColors as Bt,makeAxisFormat as nt}from"./chartShell.js";const r=52,Ot=16,l=16,Et=44;export function renderScatterPanel(j){const{title:ot="Test scatter",items:B,testDefs:_,colorScheme:it="default",onSaveImage:lt,groups:P}=j,{card:T,body:C,controlsRow:X}=Mt(ot,lt),R=_.filter(e=>e.testNumber!==void 0),b=!!(P&&P.length>0),Y=(P??[]).map(e=>e.key),at=new Map(Y.map((e,n)=>[e,n]));let m=j.xTestNumber??R[0]?.testNumber??null,f=j.yTestNumber??R[1]?.testNumber??m,O=null;const q=()=>O!==null?B[O]?[B[O]]:[]:B;function K(e,n,o){const i=document.createElement("label");Object.assign(i.style,{display:"inline-flex",alignItems:"center",gap:"4px",fontSize:"11px",color:M.label});const d=document.createElement("span");d.textContent=e;const p=kt(R,n,o,{maxWidth:"180px",emptyText:"No tests"});return i.append(d,p),{wrap:i,select:p}}const{wrap:st,select:rt}=K("X:",m,e=>{m=e,H()}),{wrap:ct,select:dt}=K("Y:",f,e=>{f=e,H()});X.append(st,ct),!b&&B.length>1&&X.appendChild(vt(B,O,e=>{O=e,H()}));const E=document.createElement("div");Object.assign(E.style,{color:M.warnText,background:M.warnBg,border:`1px solid ${M.warnBorder}`,borderRadius:"4px",padding:"4px 8px",fontSize:"11px",marginBottom:"4px",display:"none"}),T.insertBefore(E,C);function J(){const e=b?[]:St(q(),{facetableOnly:!0}).filter(n=>n.splittable).map(n=>n.label);e.length>0?(E.textContent=`\u26A0 Mixed ${e.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.`,E.style.display=""):E.style.display="none"}J();const D=document.createElement("div");D.textContent=b?"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",Object.assign(D.style,{color:M.label,fontSize:"11px",marginBottom:"4px"}),T.insertBefore(D,C);const pt=ht(it),{forBin:mt}=pt,G=e=>b?e.group??"\u2014":String(e.hbin??0),Q=e=>b?gt(at.get(e)??0):mt(Number(e)),V=e=>b?e:e==="0"?"No bin data":`Bin ${e}`,g=new Set,A=document.createElement("div");Object.assign(A.style,{display:"flex",flexWrap:"wrap",gap:"4px",marginBottom:"6px"}),C.appendChild(A);const u=document.createElement("canvas");u.style.display="block",u.style.cursor="crosshair",C.appendChild(u);const F=window.devicePixelRatio||1,Ht=wt(T);let h=[],w=0,I=1,k=0,N=1;function $(e){const n=_.find(o=>o.testNumber===e);return{unit:n?.unit,limitLow:n?.limitLow,limitHigh:n?.limitHigh}}function xt(){const e=C.clientWidth,n=Math.max(200,Math.min(400,e*.65)),o=Lt(T,C,u,n);return{w:e,h:o,plotW:Math.max(10,e-r-Ot),plotH:Math.max(10,o-l-Et)}}function Z(){for(const e of A.querySelectorAll("[data-cat]")){const n=e.dataset.cat,o=g.size===0||g.has(n);e.style.opacity=o?"1":"0.35";const i=g.has(n);e.style.boxShadow=i?`0 0 0 2px ${M.text}`:"none",e.setAttribute("aria-pressed",i?"true":"false")}}function tt(e){A.innerHTML="",g.clear();for(const n of e){const o=document.createElement("button");o.type="button",o.dataset.cat=n,o.title=`${V(n)} \u2014 click to filter`;const i=Q(n);Object.assign(o.style,{display:"inline-flex",alignItems:"center",gap:"4px",padding:"2px 7px",borderRadius:"10px",border:`1px solid ${M.menuBorder}`,background:"none",cursor:"pointer",fontSize:"11px",color:M.text,whiteSpace:"nowrap"});const d=document.createElement("span");Object.assign(d.style,{display:"inline-block",width:"9px",height:"9px",borderRadius:"50%",background:i,flexShrink:"0"}),o.append(d,document.createTextNode(V(n))),o.addEventListener("click",()=>{g.has(n)?g.delete(n):g.add(n),Z(),z()}),A.appendChild(o)}Z()}function z(){Ct(u,A.offsetHeight);const e=Bt(T),n=I-w,o=N-k,{w:i,h:d,plotW:p,plotH:c}=xt();u.width=Math.max(1,Math.floor(i*F)),u.height=Math.max(1,Math.floor(d*F)),u.style.width=`${i}px`,u.style.height=`${d}px`;const t=u.getContext("2d");if(t.setTransform(F,0,0,F,0,0),t.clearRect(0,0,i,d),h.length===0){t.font="12px system-ui, sans-serif",t.fillStyle=e.textMuted,t.textAlign="center",t.textBaseline="middle",t.fillText("No data \u2014 select two parametric tests with values.",i/2,d/2);return}const y=4,v=nt(Math.max(Math.abs(w),Math.abs(I)),m!==null?$(m).unit:void 0),U=nt(Math.max(Math.abs(k),Math.abs(N)),f!==null?$(f).unit:void 0);t.font="10px system-ui, sans-serif",t.strokeStyle=e.border,t.lineWidth=.5,t.fillStyle=e.textMuted;for(let a=0;a<=y;a++){const S=w+n*a/y,s=r+a/y*p;t.beginPath(),t.moveTo(s,l),t.lineTo(s,l+c),t.stroke(),t.textAlign="center",t.textBaseline="top",t.fillText(v.tick(S),s,l+c+4);const L=k+o*a/y,x=l+(1-a/y)*c;t.beginPath(),t.moveTo(r,x),t.lineTo(r+p,x),t.stroke(),t.textAlign="right",t.textBaseline="middle",t.fillText(U.tick(L),r-4,x)}v.unitLabel&&At(t,v.unitLabel,r+p/2,l+c+24,e.textMuted),U.unitLabel&&(t.save(),t.font="10px system-ui, sans-serif",t.fillStyle=e.textMuted,t.textAlign="center",t.textBaseline="middle",t.translate(6,l+c/2),t.rotate(-Math.PI/2),t.fillText(`(${U.unitLabel})`,0,0),t.restore());const W=g.size===0?h:h.filter(a=>g.has(G(a))),et=W.length>5e3?Math.ceil(W.length/5e3):1;t.globalAlpha=Math.max(.15,Math.min(.7,200/(W.length/et)));for(let a=0;a<W.length;a+=et){const S=W[a],s=r+(S.x-w)/n*p,L=l+(1-(S.y-k)/o)*c;t.beginPath(),t.arc(s,L,2.5,0,Math.PI*2),t.fillStyle=Q(G(S)),t.fill()}if(t.globalAlpha=1,m!==null&&f!==null){const a=$(m),S=$(f);t.strokeStyle=e.textMuted,t.fillStyle=e.textMuted,t.lineWidth=1,t.setLineDash([4,3]),t.font="9px system-ui, sans-serif",t.textBaseline="top";for(const[s,L]of[[a.limitLow,"LSL"],[a.limitHigh,"USL"]]){if(s===void 0||s<w||s>I)continue;const x=r+(s-w)/n*p;t.beginPath(),t.moveTo(x,l),t.lineTo(x,l+c),t.stroke(),t.textAlign="left",t.fillText(L,x+2,l+2)}for(const[s,L]of[[S.limitLow,"LSL"],[S.limitHigh,"USL"]]){if(s===void 0||s<k||s>N)continue;const x=l+(1-(s-k)/o)*c;t.beginPath(),t.moveTo(r,x),t.lineTo(r+p,x),t.stroke(),t.textAlign="left",t.fillText(L,r+3,x+2)}t.setLineDash([])}t.strokeStyle=e.border,t.lineWidth=1,t.beginPath(),t.moveTo(r,l),t.lineTo(r,l+c),t.moveTo(r,l+c),t.lineTo(r+p,l+c),t.stroke()}function H(){if(J(),R.length<2||m===null||f===null){h=[],tt([]),z();return}h=b?yt(P,m,f):bt(q(),m,f);let e;if(b){const n=new Set(h.map(G));e=Y.filter(o=>n.has(o))}else e=Array.from(new Set(h.map(n=>n.hbin??0))).sort((n,o)=>n-o).map(String);if(tt(e),h.length>0){const n=h.map(v=>v.x),o=h.map(v=>v.y),i=Math.min(...n),d=Math.max(...n),p=Math.min(...o),c=Math.max(...o),t=(d-i)*.05||1,y=(c-p)*.05||1;w=i-t,I=d+t,k=p-y,N=c+y}z()}const ft=Tt(T,()=>z());H();function ut(e,n){m=e,f=n,rt.value=String(e),dt.value=String(n),H()}return{card:T,setXY:ut,destroy:()=>ft.disconnect()}}
@@ -0,0 +1,41 @@
1
+ import type { Die } from '../core/dies.js';
2
+ import { type TestDef } from '../renderer/buildWaferMap.js';
3
+ import { type SaveTextHandler } from './toolbar.js';
4
+ export interface DieListOptions {
5
+ /** Heading text. Default: `Die list (N)`. */
6
+ title?: string;
7
+ /** Optional note shown under the heading, e.g. explaining why this replaced the map. */
8
+ note?: string;
9
+ /** CSV filename, including extension. Default: `'dies.csv'`. */
10
+ csvFilename?: string;
11
+ /** Host hook for "Export CSV" — see `saveTextFile` (toolbar.ts). */
12
+ onSaveText?: SaveTextHandler;
13
+ /**
14
+ * Extra leading column, e.g. a wafer-id label for a lot-level combined
15
+ * list spanning multiple wafers. `get(die)` returning `undefined` renders
16
+ * an empty cell for that row.
17
+ */
18
+ extraColumn?: {
19
+ label: string;
20
+ get: (die: Die) => string | undefined;
21
+ };
22
+ /**
23
+ * Cap the scrolling table at this CSS length instead of letting it fill.
24
+ *
25
+ * Default (omitted) is **fill**: the table grows to consume whatever height
26
+ * its container gives it (`flex: 1; min-height: 0`), which is what every
27
+ * in-library caller wants — a card overlay, a footer panel and a resizable
28
+ * modal all have a definite height, and a fixed cap left a short table
29
+ * floating in a half-empty box. In a container with NO definite height the
30
+ * table simply renders at its natural full length and the host page
31
+ * scrolls; set this if that isn't wanted (e.g. mounting a 10k-row list
32
+ * directly into a long document).
33
+ */
34
+ maxHeight?: string;
35
+ }
36
+ /**
37
+ * Build a scrollable die-list table + "Export CSV" button. Returns `null`
38
+ * when `dies` is empty (nothing to show).
39
+ */
40
+ export declare function buildDieListSection(dies: Die[], testDefs: TestDef[] | undefined, options?: DieListOptions): HTMLDivElement | null;
41
+ //# sourceMappingURL=dieList.d.ts.map
@@ -0,0 +1,2 @@
1
+ import{hasPosition as S}from"../core/dies.js";import{isParametricTest as v}from"../renderer/buildWaferMap.js";import{CLR as r,saveTextFile as w}from"./toolbar.js";import{csvField as C}from"./summaryPanel.js";import{fmt as y}from"../renderer/fmt.js";function o(n,d,t){const l=document.createElement(n);return d&&Object.assign(l.style,d),t!==void 0&&(l.textContent=t),l}function B(n){return S(n)?`(${n.x}, ${n.y})`:"\u2014"}function $(n,d){if(d?.length)return d.filter(v);const t=new Map;for(const l of n)for(const a of Object.keys(l.testValues??{})){const s=Number(a);t.has(s)||t.set(s,{testNumber:s,name:`Test ${s}`})}return[...t.values()].sort((l,a)=>l.testNumber-a.testNumber)}export function buildDieListSection(n,d,t={}){if(!n.length)return null;const l=$(n,d),a=o("div",{display:"flex",flexDirection:"column",gap:"8px",flex:"1",minHeight:"0"}),s=o("div",{display:"flex",alignItems:"center",justifyContent:"space-between",flexShrink:"0"});s.appendChild(o("div",{fontSize:"10px",fontWeight:"700",letterSpacing:"0.06em",textTransform:"uppercase",color:r.label},t.title??`Die list (${n.length})`));const f=o("button",{fontSize:"11px",padding:"3px 8px",borderRadius:"4px",border:`1px solid ${r.menuBorder}`,background:r.menuBg,color:r.text,cursor:"pointer"},"Export CSV");f.type="button",s.appendChild(f),a.appendChild(s),t.note&&a.appendChild(o("div",{fontSize:"11px",color:r.label,lineHeight:"1.4",flexShrink:"0"},t.note));const m=o("div",{overflow:"auto",flex:"1",minHeight:"0",border:`1px solid ${r.menuBorder}`,borderRadius:"4px",...t.maxHeight?{maxHeight:t.maxHeight}:{}}),c=o("table",{width:"100%",borderCollapse:"collapse",fontSize:"11px"});c.setAttribute("aria-label",t.title??`Die list (${n.length} dies)`);const u=[...t.extraColumn?[{label:t.extraColumn.label,get:e=>t.extraColumn.get(e)??""}]:[],{label:"Position",get:B},{label:"Site",get:e=>e.siteNum!==void 0?String(e.siteNum):""},{label:"Hard bin",get:e=>e.hbin!==void 0?String(e.hbin):""},{label:"Soft bin",get:e=>e.sbin!==void 0?String(e.sbin):""},...l.map(e=>({label:e.name,get:i=>{const p=i.testValues?.[e.testNumber];if(p!==void 0)return y(p,e.unit);const g=i.testPass?.[e.testNumber];return g===void 0?"":g?"PASS":"FAIL"}}))],b=o("thead"),x=o("tr");for(const e of u){const i=o("th",{textAlign:"left",padding:"4px 8px",position:"sticky",top:"0",background:r.panelBg,color:r.label,fontWeight:"600",borderBottom:`1px solid ${r.menuBorder}`,whiteSpace:"nowrap"},e.label);i.scope="col",x.appendChild(i)}b.appendChild(x),c.appendChild(b);const h=o("tbody");for(const e of n){const i=o("tr");for(const p of u)i.appendChild(o("td",{padding:"3px 8px",color:r.text,borderBottom:`1px solid ${r.menuBorder}`,whiteSpace:"nowrap"},p.get(e)));h.appendChild(i)}return c.appendChild(h),m.appendChild(c),a.appendChild(m),f.addEventListener("click",()=>{const e=[u.map(i=>C(i.label)).join(",")];for(const i of n)e.push(u.map(p=>C(p.get(i))).join(","));w(e.join(`
2
+ `),t.csvFilename??"dies.csv","text/csv",t.onSaveText)}),a}