@data-profile/fingerprint 0.0.0-stage → 0.2.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/CHANGELOG.md +19 -0
- package/LICENSE +21 -0
- package/README.md +345 -2
- package/dist/chunk-eygy4k4w.js +669 -0
- package/dist/chunk-eygy4k4w.js.map +18 -0
- package/dist/index.d.ts +350 -0
- package/dist/index.js +25 -0
- package/dist/index.js.map +9 -0
- package/dist/react/index.d.ts +300 -0
- package/dist/react/index.js +632 -0
- package/dist/react/index.js.map +13 -0
- package/dist/styles.css +117 -0
- package/package.json +72 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.2.0](https://github.com/SirKettle/data-profile/compare/fingerprint-v0.1.0...fingerprint-v0.2.0) (2026-10-26)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### ⚠ BREAKING CHANGES
|
|
7
|
+
|
|
8
|
+
* the package is now @data-profile/fingerprint. Install it in place of @thirkettle/collection-fingerprint and change imports from "@thirkettle/collection-fingerprint" (and "/react", "/styles.css") to "@data-profile/fingerprint" (and "/react", "/styles.css"). Nothing else changes.
|
|
9
|
+
|
|
10
|
+
### Features
|
|
11
|
+
|
|
12
|
+
* publish as @data-profile/fingerprint, from a Bun workspace ([6166a06](https://github.com/SirKettle/data-profile/commit/6166a0689a7761fb68ab3492eba2c57acc0a67f2))
|
|
13
|
+
|
|
14
|
+
## 0.1.0 (2026-10-22)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
### Features
|
|
18
|
+
|
|
19
|
+
* import @thirkettle/collection-fingerprint ([c214fbe](https://github.com/SirKettle/collection-fingerprint/commit/c214fbedebc0c8c6faf421272f50d1d2be9cf2ec))
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Will Thirkettle
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,346 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @data-profile/fingerprint
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Draw any collection as a fingerprint: one mark per item, laid along the ridges of a real-looking
|
|
4
|
+
fingerprint, grouped and coloured by whatever you know about the items. A library by genre, a
|
|
5
|
+
record collection by decade, a garden by plant family. The print's form comes from the data
|
|
6
|
+
itself, so every collection gets its own, and it stays the same each time you draw it.
|
|
7
|
+
|
|
8
|
+
It is not device or browser fingerprinting: nothing here identifies anyone.
|
|
9
|
+
|
|
10
|
+
**[Documentation and live demos](https://sirkettle.github.io/data-profile/)**
|
|
11
|
+
|
|
12
|
+
- **Deterministic.** The same items always draw the same print, in any order, at any size.
|
|
13
|
+
- **Honest.** Mark area is the item's weight, unknown values are drawn rather than dropped, and when
|
|
14
|
+
there are more items than marks, the print says how many items each mark stands for.
|
|
15
|
+
- **Several things at once.** Colour, shape and lightness can each show a different property.
|
|
16
|
+
- **Your own marks.** Simple shapes by default; a renderer can draw anything in SVG.
|
|
17
|
+
- **Small.** No runtime dependencies. The layout works without React; the components need React 18
|
|
18
|
+
or 19.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
npm install @data-profile/fingerprint
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## A first fingerprint
|
|
27
|
+
|
|
28
|
+
<!-- example: first-fingerprint.tsx -->
|
|
29
|
+
|
|
30
|
+
```tsx
|
|
31
|
+
import type { FingerprintDimension, FingerprintItem } from "@data-profile/fingerprint";
|
|
32
|
+
import {
|
|
33
|
+
FingerprintCanvas,
|
|
34
|
+
FingerprintLegend,
|
|
35
|
+
useFingerprint,
|
|
36
|
+
} from "@data-profile/fingerprint/react";
|
|
37
|
+
import "@data-profile/fingerprint/styles.css";
|
|
38
|
+
|
|
39
|
+
const dimensions: FingerprintDimension[] = [
|
|
40
|
+
{
|
|
41
|
+
id: "genre",
|
|
42
|
+
label: "Genre",
|
|
43
|
+
categories: [
|
|
44
|
+
{ id: "fiction", label: "Fiction" },
|
|
45
|
+
{ id: "poetry", label: "Poetry" },
|
|
46
|
+
{ id: "history", label: "History" },
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
const items: FingerprintItem[] = [
|
|
52
|
+
{ id: "b1", values: { genre: "fiction" } },
|
|
53
|
+
{ id: "b2", values: { genre: "poetry" } },
|
|
54
|
+
{ id: "b3", values: { genre: "fiction" } },
|
|
55
|
+
{ id: "b4", values: { genre: null } }, // unknown: drawn, in its own category
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
export function Library() {
|
|
59
|
+
const fp = useFingerprint({ items, dimensions, unit: "books" });
|
|
60
|
+
return (
|
|
61
|
+
<div style={{ maxWidth: 420 }}>
|
|
62
|
+
<FingerprintCanvas {...fp.canvasProps} title="My library" />
|
|
63
|
+
{fp.layout.legends.map((group) => (
|
|
64
|
+
<FingerprintLegend key={group.dimensionId} {...fp.legendProps(group)} />
|
|
65
|
+
))}
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The canvas is an SVG that fills its container's width. The stylesheet is optional: it styles the
|
|
72
|
+
legend, list and view switch, and everything works without it.
|
|
73
|
+
|
|
74
|
+
`fp.canvasProps` carries the layout, its description, the selection and the unit, and
|
|
75
|
+
`fp.legendProps(group)` the legend group and the selection, so clicking a legend entry or a mark
|
|
76
|
+
selects a category everywhere at once.
|
|
77
|
+
|
|
78
|
+
## Items and dimensions
|
|
79
|
+
|
|
80
|
+
An **item** is one thing in the collection: a unique, stable `id`, an optional `weight` (mark area
|
|
81
|
+
is proportional to it; the default is 1), and its category in each dimension, by id, in `values`.
|
|
82
|
+
|
|
83
|
+
A **dimension** is one way of grouping the items: an `id`, a `label` and its `categories` in the
|
|
84
|
+
order you want them drawn. A category can carry its own `color` and an `href`. Mark a dimension
|
|
85
|
+
`ordered: true` when its categories run in a meaningful order, such as decades or sizes.
|
|
86
|
+
|
|
87
|
+
A value that is `null`, `undefined`, missing, or not one of the dimension's categories counts as
|
|
88
|
+
**unknown**. Unknown items are drawn, last, in their own category (labelled by `unknownLabel`,
|
|
89
|
+
default "Unknown").
|
|
90
|
+
|
|
91
|
+
**Treat the items array as immutable.** The layout caches work against the array you pass, so a
|
|
92
|
+
changed collection should be a new array, as React state usually is.
|
|
93
|
+
|
|
94
|
+
## Encodings: what each property shows
|
|
95
|
+
|
|
96
|
+
One dimension **arranges** the marks: it sets the order along the ridges, so its categories form
|
|
97
|
+
runs. **Colour**, **shape** and **lightness** can each show a dimension too:
|
|
98
|
+
|
|
99
|
+
<!-- example: encoding.ts -->
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
import type { FingerprintEncoding } from "@data-profile/fingerprint";
|
|
103
|
+
|
|
104
|
+
export const encoding: FingerprintEncoding = {
|
|
105
|
+
arrange: "genre",
|
|
106
|
+
color: "genre",
|
|
107
|
+
shape: "state",
|
|
108
|
+
lightness: "decade",
|
|
109
|
+
};
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
- Leave `color` and `shape` out and they follow `arrange`; set one to `null` to turn it off.
|
|
113
|
+
- **Lightness needs an ordered dimension**: the first category is faintest. On unordered categories
|
|
114
|
+
fainter and stronger would read as less and more, so that is an error.
|
|
115
|
+
- Colour stays distinguishable to about 10 categories and the default shapes to 5; beyond those,
|
|
116
|
+
`layout.warnings` says so and only the legend tells them apart.
|
|
117
|
+
- Size is never an encoding: mark area is reserved for weight.
|
|
118
|
+
|
|
119
|
+
### Views
|
|
120
|
+
|
|
121
|
+
A **view** is a named encoding. Pass several to `useFingerprint` and switch between them with
|
|
122
|
+
`FingerprintViewSwitch`. Marks glide to their new places, and the ridges stay put.
|
|
123
|
+
|
|
124
|
+
<!-- example: views.tsx -->
|
|
125
|
+
|
|
126
|
+
```tsx
|
|
127
|
+
import type { FingerprintDimension, FingerprintItem } from "@data-profile/fingerprint";
|
|
128
|
+
import {
|
|
129
|
+
FingerprintCanvas,
|
|
130
|
+
FingerprintViewSwitch,
|
|
131
|
+
useFingerprint,
|
|
132
|
+
type FingerprintView,
|
|
133
|
+
} from "@data-profile/fingerprint/react";
|
|
134
|
+
|
|
135
|
+
const views: FingerprintView[] = [
|
|
136
|
+
{
|
|
137
|
+
id: "all",
|
|
138
|
+
label: "Combined",
|
|
139
|
+
encoding: { arrange: "genre", color: "genre", shape: "state", lightness: "decade" },
|
|
140
|
+
},
|
|
141
|
+
{ id: "genre", label: "Genre", encoding: { arrange: "genre" } },
|
|
142
|
+
{ id: "decade", label: "Decade", encoding: { arrange: "decade" } },
|
|
143
|
+
];
|
|
144
|
+
|
|
145
|
+
export function Shelves({
|
|
146
|
+
items,
|
|
147
|
+
dimensions,
|
|
148
|
+
}: {
|
|
149
|
+
items: FingerprintItem[];
|
|
150
|
+
dimensions: FingerprintDimension[];
|
|
151
|
+
}) {
|
|
152
|
+
const fp = useFingerprint({ items, dimensions, views, unit: "books" });
|
|
153
|
+
return (
|
|
154
|
+
<>
|
|
155
|
+
<FingerprintViewSwitch views={fp.views} value={fp.viewId} onChange={fp.setViewId} />
|
|
156
|
+
<FingerprintCanvas {...fp.canvasProps} title="My library" />
|
|
157
|
+
</>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
With no `views`, there is one per dimension.
|
|
163
|
+
|
|
164
|
+
## The print's form
|
|
165
|
+
|
|
166
|
+
Fingerprints come in three patterns: **loop**, **whorl** and **arch**. `formFromItems(items,
|
|
167
|
+
dimensions)`, which `useFingerprint` uses, picks one from the data (an arch under 12 items, a loop
|
|
168
|
+
when one category of the first dimension holds 40% or more, otherwise a whorl) and places the core
|
|
169
|
+
from where the second and third dimensions' values sit. It changes in coarse steps, so a collection
|
|
170
|
+
keeps its print as it grows by an item or two. Pass `form` to override any part of it.
|
|
171
|
+
|
|
172
|
+
("Form" is the print's geometry. A mark's _shape_ is an encoding, above.)
|
|
173
|
+
|
|
174
|
+
## Selection, legends and lists
|
|
175
|
+
|
|
176
|
+
A selection is a category _of a dimension_, so choosing "Unread" in a shape legend highlights unread
|
|
177
|
+
items whatever their colour. Choose a category in a legend, a list or by clicking a mark; choose it
|
|
178
|
+
again to clear. The canvas dims everything else, and announces the choice to screen readers.
|
|
179
|
+
|
|
180
|
+
`FingerprintList` is one dimension as a table: every category with its count and share. Render one
|
|
181
|
+
per legend group with `{...fp.listProps(group)}`; together they are the accessible equivalent of the
|
|
182
|
+
drawing.
|
|
183
|
+
|
|
184
|
+
## Callouts
|
|
185
|
+
|
|
186
|
+
`annotations` pins short notes to the print. You write the words; the canvas points a leader at a
|
|
187
|
+
real mark of the categories you name:
|
|
188
|
+
|
|
189
|
+
<!-- example: annotations.ts -->
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
import type { FingerprintAnnotation } from "@data-profile/fingerprint";
|
|
193
|
+
|
|
194
|
+
export const annotations: FingerprintAnnotation[] = [
|
|
195
|
+
{
|
|
196
|
+
id: "lead",
|
|
197
|
+
dimensionId: "genre",
|
|
198
|
+
categoryIds: ["fiction"],
|
|
199
|
+
title: "Mostly fiction",
|
|
200
|
+
body: "62% of the library.",
|
|
201
|
+
},
|
|
202
|
+
];
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Callouts sit beside the print when its container is at least `callouts.breakpoint` wide (640 px by
|
|
206
|
+
default) and are numbered beneath it otherwise; `callouts.placement` can force either.
|
|
207
|
+
|
|
208
|
+
## Renderers
|
|
209
|
+
|
|
210
|
+
A renderer draws one mark, centred on the origin, `node.size` across, with the ridge running along
|
|
211
|
+
the x axis; the canvas positions and rotates it. Each mark arrives with its properties resolved:
|
|
212
|
+
|
|
213
|
+
| `node.` | Holds |
|
|
214
|
+
| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
|
|
215
|
+
| `color` | The colour dimension's colour for it, or `currentColor` when nothing drives colour |
|
|
216
|
+
| `shape` | The index of its category in the shape dimension (0, 1, 2, …); `0` when nothing drives shape; `null` when its shape category is unknown |
|
|
217
|
+
| `lightness` | 0 (faintest) to 1 along the lightness dimension, or `null`. `markOpacity(node)` turns it into an opacity |
|
|
218
|
+
| `categories` | Its category in every encoded dimension, by dimension id |
|
|
219
|
+
| `size`, `weight`, `itemIds` | Its footprint, summed weight and the items it stands for |
|
|
220
|
+
|
|
221
|
+
<!-- example: renderer.tsx -->
|
|
222
|
+
|
|
223
|
+
```tsx
|
|
224
|
+
import { markOpacity } from "@data-profile/fingerprint";
|
|
225
|
+
import type { FingerprintRenderer } from "@data-profile/fingerprint/react";
|
|
226
|
+
|
|
227
|
+
const glyphs = ["★", "●", "■", "▲", "◆"];
|
|
228
|
+
|
|
229
|
+
export const stars: FingerprintRenderer = {
|
|
230
|
+
name: "stars",
|
|
231
|
+
mark: ({ node }) => (
|
|
232
|
+
<text
|
|
233
|
+
fontSize={node.size}
|
|
234
|
+
textAnchor="middle"
|
|
235
|
+
dy="0.35em"
|
|
236
|
+
fill={node.color}
|
|
237
|
+
opacity={markOpacity(node)}
|
|
238
|
+
>
|
|
239
|
+
{node.shape === null ? "?" : glyphs[node.shape % glyphs.length]}
|
|
240
|
+
</text>
|
|
241
|
+
),
|
|
242
|
+
};
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
`shapesRenderer` (the default, five shapes) and `dotsRenderer` come with the package. A renderer can
|
|
246
|
+
also return SVG `defs` once per drawing. Marks are memoised, so a renderer must depend on its props
|
|
247
|
+
alone.
|
|
248
|
+
|
|
249
|
+
## Large collections and motion
|
|
250
|
+
|
|
251
|
+
At most about `maxMarks` marks are drawn (600 by default). Above that, neighbouring items that
|
|
252
|
+
agree on every encoded dimension share a mark, `layout.itemsPerMark` says how many, and the
|
|
253
|
+
description says so too. Nothing is sampled away.
|
|
254
|
+
|
|
255
|
+
Marks glide between views while there are at most `motion.glide` of them (800) and recolour gently
|
|
256
|
+
up to `motion.fade` (400); above those they change at once, because SVG cannot animate more marks
|
|
257
|
+
smoothly. Under `prefers-reduced-motion` nothing animates.
|
|
258
|
+
|
|
259
|
+
Ridges are drawn faintly behind small collections, where the marks alone would not read as a
|
|
260
|
+
fingerprint; `ridges={{ show: "always" }}` or `"never"` overrides that.
|
|
261
|
+
|
|
262
|
+
## Without React
|
|
263
|
+
|
|
264
|
+
<!-- example: headless.ts -->
|
|
265
|
+
|
|
266
|
+
```ts
|
|
267
|
+
import {
|
|
268
|
+
describeLayout,
|
|
269
|
+
formFromItems,
|
|
270
|
+
layoutFingerprint,
|
|
271
|
+
type FingerprintDimension,
|
|
272
|
+
type FingerprintItem,
|
|
273
|
+
} from "@data-profile/fingerprint";
|
|
274
|
+
|
|
275
|
+
export function summarise(items: FingerprintItem[], dimensions: FingerprintDimension[]) {
|
|
276
|
+
const layout = layoutFingerprint({
|
|
277
|
+
items,
|
|
278
|
+
dimensions,
|
|
279
|
+
encoding: { arrange: "genre" },
|
|
280
|
+
form: formFromItems(items, dimensions),
|
|
281
|
+
maxMarks: 600,
|
|
282
|
+
});
|
|
283
|
+
return {
|
|
284
|
+
description: describeLayout(layout, "books"),
|
|
285
|
+
pattern: layout.form.pattern,
|
|
286
|
+
marks: layout.nodes.length,
|
|
287
|
+
warnings: layout.warnings,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
`layout.nodes` are the positioned marks, `layout.legends` one legend per encoded dimension,
|
|
293
|
+
`layout.ridges` the ridge polylines, all in a fixed `layout.width` × `layout.height` frame.
|
|
294
|
+
`layoutFingerprint` throws `FingerprintDataError` on data it cannot draw truthfully (duplicate ids,
|
|
295
|
+
lightness on an unordered dimension, …); `useFingerprint` returns it as `fp.error` instead.
|
|
296
|
+
|
|
297
|
+
## Reference
|
|
298
|
+
|
|
299
|
+
### `useFingerprint(options)`
|
|
300
|
+
|
|
301
|
+
| Option | | Default |
|
|
302
|
+
| --------------------- | ----------------------------------- | ----------------- |
|
|
303
|
+
| `items`, `dimensions` | The data | required |
|
|
304
|
+
| `views` | Named encodings, in switcher order | one per dimension |
|
|
305
|
+
| `initialViewId` | The view shown first | the first |
|
|
306
|
+
| `form` | Overrides parts of the derived form | from the data |
|
|
307
|
+
| `seed` | Varies the form's free parts | `"fingerprint"` |
|
|
308
|
+
| `maxMarks` | The mark cap | 600 |
|
|
309
|
+
| `unit` | What one item is ("books") | `"items"` |
|
|
310
|
+
|
|
311
|
+
Returns `layout`, `description`, `error`, `views`, `view`, `viewId`, `setViewId`, `selected`,
|
|
312
|
+
`setSelected`, and the bundles `canvasProps`, `legendProps(group)` and `listProps(group)`.
|
|
313
|
+
|
|
314
|
+
### `<FingerprintCanvas>`
|
|
315
|
+
|
|
316
|
+
| Prop | | Default |
|
|
317
|
+
| ------------------------------------------------------- | -------------------------------------------------------------------- | ---------------- |
|
|
318
|
+
| `layout`, `description`, `selected`, `onSelect`, `unit` | From `fp.canvasProps` | |
|
|
319
|
+
| `title` | The drawing's accessible name | required |
|
|
320
|
+
| `renderer` | Draws each mark | `shapesRenderer` |
|
|
321
|
+
| `annotations` | Callouts | none |
|
|
322
|
+
| `callouts` | `{ placement?: "auto" \| "beside" \| "below", breakpoint?: number }` | `auto`, 640 |
|
|
323
|
+
| `motion` | `{ glide?: number, fade?: number }` | 800, 400 |
|
|
324
|
+
| `ridges` | `{ show?, fadeFrom?, fadeTo?, opacity?, color?, width? }` | `show: "auto"` |
|
|
325
|
+
| `className` | Extra class | |
|
|
326
|
+
|
|
327
|
+
### `<FingerprintLegend>` and `<FingerprintList>`
|
|
328
|
+
|
|
329
|
+
Both take `group`, `selected` and `onSelect` (from `fp.legendProps(group)` or `fp.listProps(group)`)
|
|
330
|
+
and a `className`. The legend also takes `renderer` (match the canvas), `heading`, `counts` and
|
|
331
|
+
`hideEmpty`; the list takes `unit` and `linkLabel`.
|
|
332
|
+
|
|
333
|
+
### `<FingerprintViewSwitch>`
|
|
334
|
+
|
|
335
|
+
`views`, `value` (the active id), `onChange`, and an accessible `label` (default "View").
|
|
336
|
+
|
|
337
|
+
## Styling
|
|
338
|
+
|
|
339
|
+
The components are unstyled apart from what layout needs, and every part has an `fp-` class:
|
|
340
|
+
`fp-canvas`, `fp-mark`, `fp-legend`, `fp-legend__toggle`, `fp-list`, `fp-views__view` and others.
|
|
341
|
+
The optional `@data-profile/fingerprint/styles.css` styles them neutrally; theme it with
|
|
342
|
+
`--fp-accent`, `--fp-accent-text`, `--fp-muted`, `--fp-border` and `--fp-radius`.
|
|
343
|
+
|
|
344
|
+
## Licence
|
|
345
|
+
|
|
346
|
+
MIT
|