@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.
- package/AGENTS.md +181 -0
- package/CHANGELOG.md +261 -1
- package/README.md +9 -1
- package/dist/packages/canvas-adapter/charts/histogram.js +1 -1
- package/dist/packages/canvas-adapter/charts/scatter.js +1 -1
- package/dist/packages/canvas-adapter/dieList.d.ts +41 -0
- package/dist/packages/canvas-adapter/dieList.js +2 -0
- package/dist/packages/canvas-adapter/icons.js +1 -1
- package/dist/packages/canvas-adapter/index.d.ts +4 -0
- package/dist/packages/canvas-adapter/index.js +1 -1
- package/dist/packages/canvas-adapter/maplessSummary.d.ts +34 -0
- package/dist/packages/canvas-adapter/maplessSummary.js +1 -0
- package/dist/packages/canvas-adapter/renderWaferGallery.d.ts +9 -11
- package/dist/packages/canvas-adapter/renderWaferGallery.js +8 -8
- package/dist/packages/canvas-adapter/renderWaferMap.d.ts +31 -1
- package/dist/packages/canvas-adapter/renderWaferMap.js +1 -1
- package/dist/packages/canvas-adapter/summaryPanel.d.ts +13 -1
- package/dist/packages/canvas-adapter/summaryPanel.js +3 -3
- package/dist/packages/canvas-adapter/toolbar.d.ts +4 -1
- package/dist/packages/canvas-adapter/toolbar.js +1 -1
- package/dist/packages/canvas-adapter/userGuideHtml.d.ts +1 -1
- package/dist/packages/canvas-adapter/userGuideHtml.js +59 -22
- package/dist/packages/canvas-adapter/version.d.ts +2 -2
- package/dist/packages/canvas-adapter/version.js +1 -1
- package/dist/packages/canvas-adapter/warnings.d.ts +57 -0
- package/dist/packages/canvas-adapter/warnings.js +1 -0
- package/dist/packages/core/classify.d.ts +6 -2
- package/dist/packages/core/dies.d.ts +67 -7
- package/dist/packages/core/dies.js +1 -1
- package/dist/packages/core/probe.d.ts +5 -0
- package/dist/packages/core/probe.js +1 -1
- package/dist/packages/core/transforms.d.ts +4 -4
- package/dist/packages/core/transforms.js +1 -1
- package/dist/packages/renderer/buildView.d.ts +3 -3
- package/dist/packages/renderer/buildView.js +1 -1
- package/dist/packages/renderer/buildWaferMap.d.ts +40 -9
- package/dist/packages/renderer/buildWaferMap.js +1 -1
- package/dist/packages/renderer/index.d.ts +1 -1
- package/dist/packages/renderer/index.js +1 -1
- package/dist/packages/stats/analyzeWaferLot.js +1 -1
- package/dist/packages/stats/analyzeWaferMap.js +1 -1
- package/dist/packages/stats/clusterDetection.d.ts +2 -2
- package/dist/packages/stats/patternClassification.d.ts +2 -2
- package/dist/packages/stats/regions.d.ts +6 -6
- package/dist/packages/stats/regions.js +1 -1
- package/dist/packages/stats/renderFindingsReport.js +15 -15
- package/dist/packages/stats/renderSummaryReport.js +17 -17
- package/dist/packages/stats/types.d.ts +34 -4
- package/llms.txt +39 -0
- 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
|
-
## [
|
|
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
|
[](https://github.com/wafertools/wafermap/actions/workflows/ci.yml)
|
|
4
6
|
[](https://www.npmjs.com/package/@wafertools/wafermap)
|
|
5
7
|

|
|
@@ -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,
|
|
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
|
|
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}
|