mapped-image 0.1.1 → 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/README.md +2 -2
- package/{types.ts → dist/MappedImage.d.ts} +13 -13
- package/dist/MappedImage.js +335 -0
- package/dist/MappedImage.js.map +1 -0
- package/package.json +18 -8
- package/MappedImage.css +0 -19
- package/MappedImage.tsx +0 -84
- package/components/DefaultImageSelector.tsx +0 -33
- package/components/Grid.tsx +0 -84
- package/components/ImageLayer.tsx +0 -69
- package/helpers/cellAccessibility.ts +0 -28
- package/helpers/colNames.test.ts +0 -20
- package/helpers/colNames.ts +0 -15
- package/helpers/headerIcon.ts +0 -8
- package/helpers/imageBounds.test.ts +0 -62
- package/helpers/imageBounds.ts +0 -12
- package/helpers/weights.test.ts +0 -22
- package/helpers/weights.ts +0 -11
package/README.md
CHANGED
|
@@ -5,12 +5,12 @@ A React component that overlays a clickable row/column grid on top of one or mor
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
npm install
|
|
8
|
+
npm install mapped-image
|
|
9
9
|
```
|
|
10
10
|
|
|
11
11
|
`leaflet` and `react-leaflet` are declared as dependencies and installed automatically. `react` and `react-dom` are peer dependencies, resolved from your app.
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
The package ships compiled ESM + type declarations (built with `tsup`), so it works like any normal npm package — no extra bundler configuration needed.
|
|
14
14
|
|
|
15
15
|
## Usage
|
|
16
16
|
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { ReactNode } from 'react';
|
|
3
|
+
import { LatLngTuple } from 'leaflet';
|
|
3
4
|
|
|
4
|
-
|
|
5
|
+
type ImageConfig = {
|
|
5
6
|
name: string;
|
|
6
7
|
src: string;
|
|
7
8
|
columns: number;
|
|
@@ -11,14 +12,12 @@ export type ImageConfig = {
|
|
|
11
12
|
/** rowIndex -> relative height weight, default weight is 1. Only needs entries for non-uniform rows. */
|
|
12
13
|
rowWeights?: Record<number, number>;
|
|
13
14
|
};
|
|
14
|
-
|
|
15
|
-
export type ImageSelectorProps = {
|
|
15
|
+
type ImageSelectorProps = {
|
|
16
16
|
images: ImageConfig[];
|
|
17
17
|
selectedIndex: number;
|
|
18
18
|
onSelect: (index: number) => void;
|
|
19
19
|
};
|
|
20
|
-
|
|
21
|
-
export type MappedImageProps = {
|
|
20
|
+
type MappedImageProps = {
|
|
22
21
|
images: ImageConfig[];
|
|
23
22
|
alt: string;
|
|
24
23
|
width: number;
|
|
@@ -32,8 +31,7 @@ export type MappedImageProps = {
|
|
|
32
31
|
/** Extra pannable margin (in pixels) beyond the image edges. Defaults to 50. */
|
|
33
32
|
maxBoundsPadding?: number;
|
|
34
33
|
};
|
|
35
|
-
|
|
36
|
-
export type ImageLayerProps = {
|
|
34
|
+
type ImageLayerProps = {
|
|
37
35
|
image: ImageConfig;
|
|
38
36
|
colOffset: number;
|
|
39
37
|
rowOffset: number;
|
|
@@ -44,8 +42,7 @@ export type ImageLayerProps = {
|
|
|
44
42
|
maxBoundsPadding?: number;
|
|
45
43
|
onCellClick: GridProps["onCellClick"];
|
|
46
44
|
};
|
|
47
|
-
|
|
48
|
-
export type GridProps = {
|
|
45
|
+
type GridProps = {
|
|
49
46
|
rows: number;
|
|
50
47
|
rowOffset: number;
|
|
51
48
|
rowWeights: number[];
|
|
@@ -55,8 +52,11 @@ export type GridProps = {
|
|
|
55
52
|
selectedCells: Set<string>;
|
|
56
53
|
onCellClick?: (props: ICellClickProps) => void;
|
|
57
54
|
};
|
|
58
|
-
|
|
59
|
-
export interface ICellClickProps {
|
|
55
|
+
interface ICellClickProps {
|
|
60
56
|
col: string;
|
|
61
57
|
row: number;
|
|
62
58
|
}
|
|
59
|
+
|
|
60
|
+
declare const MappedImage: (props: MappedImageProps) => react.JSX.Element;
|
|
61
|
+
|
|
62
|
+
export { type GridProps, type ICellClickProps, type ImageConfig, type ImageLayerProps, type ImageSelectorProps, MappedImage, type MappedImageProps };
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
// MappedImage.tsx
|
|
2
|
+
import { useState as useState2 } from "react";
|
|
3
|
+
import "leaflet/dist/leaflet.css";
|
|
4
|
+
|
|
5
|
+
// #style-inject:#style-inject
|
|
6
|
+
function styleInject(css, { insertAt } = {}) {
|
|
7
|
+
if (!css || typeof document === "undefined") return;
|
|
8
|
+
const head = document.head || document.getElementsByTagName("head")[0];
|
|
9
|
+
const style = document.createElement("style");
|
|
10
|
+
style.type = "text/css";
|
|
11
|
+
if (insertAt === "top") {
|
|
12
|
+
if (head.firstChild) {
|
|
13
|
+
head.insertBefore(style, head.firstChild);
|
|
14
|
+
} else {
|
|
15
|
+
head.appendChild(style);
|
|
16
|
+
}
|
|
17
|
+
} else {
|
|
18
|
+
head.appendChild(style);
|
|
19
|
+
}
|
|
20
|
+
if (style.styleSheet) {
|
|
21
|
+
style.styleSheet.cssText = css;
|
|
22
|
+
} else {
|
|
23
|
+
style.appendChild(document.createTextNode(css));
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// MappedImage.css
|
|
28
|
+
styleInject(".grid-header-label-icon {\n background: none;\n border: none;\n}\n.grid-header-label {\n position: absolute;\n display: block;\n font-weight: bold;\n white-space: nowrap;\n}\n.grid-header-label--column {\n transform: translate(-50%, -100%);\n}\n.grid-header-label--row {\n transform: translate(-100%, -50%);\n}\n");
|
|
29
|
+
|
|
30
|
+
// components/DefaultImageSelector.tsx
|
|
31
|
+
import { jsx } from "react/jsx-runtime";
|
|
32
|
+
var DefaultImageSelector = ({ images, selectedIndex, onSelect }) => /* @__PURE__ */ jsx(
|
|
33
|
+
"div",
|
|
34
|
+
{
|
|
35
|
+
style: {
|
|
36
|
+
position: "absolute",
|
|
37
|
+
top: 10,
|
|
38
|
+
right: 10,
|
|
39
|
+
zIndex: 1e3,
|
|
40
|
+
display: "flex",
|
|
41
|
+
gap: 8
|
|
42
|
+
},
|
|
43
|
+
children: images.map((image, index) => /* @__PURE__ */ jsx(
|
|
44
|
+
"button",
|
|
45
|
+
{
|
|
46
|
+
type: "button",
|
|
47
|
+
onClick: () => onSelect(index),
|
|
48
|
+
style: {
|
|
49
|
+
padding: "12px 20px",
|
|
50
|
+
fontSize: 16,
|
|
51
|
+
fontWeight: index === selectedIndex ? "bold" : "normal",
|
|
52
|
+
background: index === selectedIndex ? "#3388ff" : "white",
|
|
53
|
+
color: index === selectedIndex ? "white" : "black",
|
|
54
|
+
border: "1px solid #3388ff",
|
|
55
|
+
borderRadius: 6
|
|
56
|
+
},
|
|
57
|
+
children: image.name
|
|
58
|
+
},
|
|
59
|
+
image.name
|
|
60
|
+
))
|
|
61
|
+
}
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
// components/ImageLayer.tsx
|
|
65
|
+
import { useEffect, useState } from "react";
|
|
66
|
+
import { CRS } from "leaflet";
|
|
67
|
+
import { MapContainer, ImageOverlay } from "react-leaflet";
|
|
68
|
+
|
|
69
|
+
// components/Grid.tsx
|
|
70
|
+
import { Rectangle, Marker } from "react-leaflet";
|
|
71
|
+
|
|
72
|
+
// helpers/weights.ts
|
|
73
|
+
var resolveWeights = (count, overrides = {}) => Array.from({ length: count }, (_, i) => overrides[i] ?? 1);
|
|
74
|
+
var weightOffsets = (weights) => {
|
|
75
|
+
const offsets = [0];
|
|
76
|
+
weights.forEach((weight) => offsets.push(offsets[offsets.length - 1] + weight));
|
|
77
|
+
return offsets;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// helpers/headerIcon.ts
|
|
81
|
+
import { divIcon } from "leaflet";
|
|
82
|
+
var headerIcon = (label, className) => divIcon({
|
|
83
|
+
html: `<span class="grid-header-label ${className}">${label}</span>`,
|
|
84
|
+
className: "grid-header-label-icon",
|
|
85
|
+
iconSize: [0, 0]
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// helpers/cellAccessibility.ts
|
|
89
|
+
var getPathElement = (instance) => instance.getElement();
|
|
90
|
+
var bindCellAccessibility = (instance, label, selected, onActivate) => {
|
|
91
|
+
const el = instance && getPathElement(instance);
|
|
92
|
+
if (!el) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
el.setAttribute("role", "button");
|
|
96
|
+
el.setAttribute("tabindex", "0");
|
|
97
|
+
el.setAttribute("aria-label", label);
|
|
98
|
+
el.setAttribute("aria-pressed", String(selected));
|
|
99
|
+
el.onkeydown = (e) => {
|
|
100
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
101
|
+
e.preventDefault();
|
|
102
|
+
onActivate();
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// components/Grid.tsx
|
|
108
|
+
import { jsx as jsx2 } from "react/jsx-runtime";
|
|
109
|
+
var Grid = ({
|
|
110
|
+
rows,
|
|
111
|
+
rowOffset,
|
|
112
|
+
rowWeights,
|
|
113
|
+
columnLabels,
|
|
114
|
+
columnWeights,
|
|
115
|
+
imgBounds,
|
|
116
|
+
selectedCells,
|
|
117
|
+
onCellClick
|
|
118
|
+
}) => {
|
|
119
|
+
const [height, width] = imgBounds;
|
|
120
|
+
const colOffsets = weightOffsets(columnWeights);
|
|
121
|
+
const pxPerColWeightUnit = width / colOffsets[colOffsets.length - 1];
|
|
122
|
+
const colLeft = (colCount) => colOffsets[colCount] * pxPerColWeightUnit;
|
|
123
|
+
const colRight = (colCount) => colOffsets[colCount + 1] * pxPerColWeightUnit;
|
|
124
|
+
const rowOffsets = weightOffsets(rowWeights);
|
|
125
|
+
const pxPerRowWeightUnit = height / rowOffsets[rowOffsets.length - 1];
|
|
126
|
+
const rowBottom = (rowCount) => rowOffsets[rowCount] * pxPerRowWeightUnit;
|
|
127
|
+
const rowTop = (rowCount) => rowOffsets[rowCount + 1] * pxPerRowWeightUnit;
|
|
128
|
+
const cells = Array.from({ length: rows }).map(
|
|
129
|
+
(_, rowCount) => columnLabels.map((columnLabel, colCount) => {
|
|
130
|
+
const bounds = [
|
|
131
|
+
[rowTop(rowCount), colRight(colCount)],
|
|
132
|
+
[rowBottom(rowCount), colLeft(colCount)]
|
|
133
|
+
];
|
|
134
|
+
const rowLabel = rowOffset + (rows - rowCount);
|
|
135
|
+
const cellId = `${rowLabel},${columnLabel}`;
|
|
136
|
+
const selected = selectedCells.has(cellId);
|
|
137
|
+
const activate = () => onCellClick?.({ col: columnLabel, row: rowLabel });
|
|
138
|
+
return /* @__PURE__ */ jsx2(
|
|
139
|
+
Rectangle,
|
|
140
|
+
{
|
|
141
|
+
ref: (instance) => bindCellAccessibility(instance, `${columnLabel}${rowLabel}`, selected, activate),
|
|
142
|
+
bounds,
|
|
143
|
+
pathOptions: {
|
|
144
|
+
fillColor: selected ? "red" : "transparent",
|
|
145
|
+
fillOpacity: selected ? 0.5 : 0,
|
|
146
|
+
weight: 1.5
|
|
147
|
+
},
|
|
148
|
+
eventHandlers: {
|
|
149
|
+
click: activate
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
cellId
|
|
153
|
+
);
|
|
154
|
+
})
|
|
155
|
+
);
|
|
156
|
+
const columnHeaders = columnLabels.map((columnLabel, colCount) => /* @__PURE__ */ jsx2(
|
|
157
|
+
Marker,
|
|
158
|
+
{
|
|
159
|
+
position: [height, (colLeft(colCount) + colRight(colCount)) / 2],
|
|
160
|
+
icon: headerIcon(columnLabel, "grid-header-label--column"),
|
|
161
|
+
interactive: false
|
|
162
|
+
},
|
|
163
|
+
`col-${columnLabel}`
|
|
164
|
+
));
|
|
165
|
+
const rowHeaders = Array.from({ length: rows }).map((_, rowCount) => {
|
|
166
|
+
const rowLabel = rowOffset + (rows - rowCount);
|
|
167
|
+
return /* @__PURE__ */ jsx2(
|
|
168
|
+
Marker,
|
|
169
|
+
{
|
|
170
|
+
position: [(rowBottom(rowCount) + rowTop(rowCount)) / 2, -7],
|
|
171
|
+
icon: headerIcon(String(rowLabel), "grid-header-label--row"),
|
|
172
|
+
interactive: false
|
|
173
|
+
},
|
|
174
|
+
`row-${rowLabel}`
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
return [...cells, columnHeaders, rowHeaders];
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
// helpers/imageBounds.ts
|
|
181
|
+
var imageBounds = (src, containerWidth, containerHeight) => new Promise((resolve, reject) => {
|
|
182
|
+
const img = new Image();
|
|
183
|
+
img.src = src;
|
|
184
|
+
img.onload = () => {
|
|
185
|
+
const scale = Math.min(containerWidth / img.width, containerHeight / img.height);
|
|
186
|
+
resolve([img.height * scale, img.width * scale]);
|
|
187
|
+
};
|
|
188
|
+
img.onerror = () => reject(new Error(`Failed to load image: ${src}`));
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// helpers/colNames.ts
|
|
192
|
+
var toColumnLetter = (n) => {
|
|
193
|
+
let result = "";
|
|
194
|
+
let remaining = n;
|
|
195
|
+
while (remaining > 0) {
|
|
196
|
+
const remainder = (remaining - 1) % 26;
|
|
197
|
+
result = String.fromCharCode(65 + remainder) + result;
|
|
198
|
+
remaining = Math.floor((remaining - 1) / 26);
|
|
199
|
+
}
|
|
200
|
+
return result;
|
|
201
|
+
};
|
|
202
|
+
var colNames = (startIndex, count) => Array.from({ length: count }, (_, i) => toColumnLetter(startIndex + i + 1));
|
|
203
|
+
|
|
204
|
+
// components/ImageLayer.tsx
|
|
205
|
+
import { jsx as jsx3, jsxs } from "react/jsx-runtime";
|
|
206
|
+
var ImageLayer = ({
|
|
207
|
+
image,
|
|
208
|
+
colOffset,
|
|
209
|
+
rowOffset,
|
|
210
|
+
width,
|
|
211
|
+
height,
|
|
212
|
+
alt,
|
|
213
|
+
selectedCells,
|
|
214
|
+
maxBoundsPadding = 100,
|
|
215
|
+
onCellClick
|
|
216
|
+
}) => {
|
|
217
|
+
const [imgBounds, setImgBounds] = useState(null);
|
|
218
|
+
const [loadError, setLoadError] = useState(null);
|
|
219
|
+
useEffect(() => {
|
|
220
|
+
imageBounds(image.src, width, height).then(setImgBounds).catch(setLoadError);
|
|
221
|
+
}, [image.src, width, height]);
|
|
222
|
+
if (loadError) {
|
|
223
|
+
throw loadError;
|
|
224
|
+
}
|
|
225
|
+
if (!imgBounds) {
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
const [imgHeight, imgWidth] = imgBounds;
|
|
229
|
+
const bounds = [[0, 0], imgBounds];
|
|
230
|
+
const maxBounds = [
|
|
231
|
+
[-maxBoundsPadding, -maxBoundsPadding],
|
|
232
|
+
[imgHeight + maxBoundsPadding, imgWidth + maxBoundsPadding]
|
|
233
|
+
];
|
|
234
|
+
const columnLabels = colNames(colOffset, image.columns);
|
|
235
|
+
const columnWeights = resolveWeights(image.columns, image.columnWeights);
|
|
236
|
+
const rowWeights = resolveWeights(image.rows, image.rowWeights);
|
|
237
|
+
return /* @__PURE__ */ jsxs(
|
|
238
|
+
MapContainer,
|
|
239
|
+
{
|
|
240
|
+
crs: CRS.Simple,
|
|
241
|
+
bounds,
|
|
242
|
+
boundsOptions: { padding: [50, 50] },
|
|
243
|
+
maxBounds,
|
|
244
|
+
maxBoundsViscosity: 0.5,
|
|
245
|
+
style: { width, height },
|
|
246
|
+
maxZoom: 2,
|
|
247
|
+
children: [
|
|
248
|
+
/* @__PURE__ */ jsx3(
|
|
249
|
+
Grid,
|
|
250
|
+
{
|
|
251
|
+
rows: image.rows,
|
|
252
|
+
rowOffset,
|
|
253
|
+
rowWeights,
|
|
254
|
+
columnLabels,
|
|
255
|
+
columnWeights,
|
|
256
|
+
imgBounds,
|
|
257
|
+
selectedCells,
|
|
258
|
+
onCellClick
|
|
259
|
+
}
|
|
260
|
+
),
|
|
261
|
+
/* @__PURE__ */ jsx3(ImageOverlay, { url: image.src, bounds, alt })
|
|
262
|
+
]
|
|
263
|
+
}
|
|
264
|
+
);
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
// MappedImage.tsx
|
|
268
|
+
import { jsx as jsx4, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
269
|
+
var MappedImage = (props) => {
|
|
270
|
+
if (props.images.length === 0) {
|
|
271
|
+
throw new Error("MappedImage requires at least one entry in `images`.");
|
|
272
|
+
}
|
|
273
|
+
const invalidImage = props.images.find((image) => image.rows <= 0 || image.columns <= 0);
|
|
274
|
+
if (invalidImage) {
|
|
275
|
+
throw new Error(
|
|
276
|
+
`MappedImage: image "${invalidImage.name}" must have rows > 0 and columns > 0.`
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
const [internalSelectedCells, setInternalSelectedCells] = useState2(
|
|
280
|
+
props.selectedCells ?? /* @__PURE__ */ new Set()
|
|
281
|
+
);
|
|
282
|
+
const isSelectionControlled = props.selectedCells !== void 0;
|
|
283
|
+
const selectedCells = isSelectionControlled ? props.selectedCells : internalSelectedCells;
|
|
284
|
+
const [selectedImageIndex, setSelectedImageIndex] = useState2(
|
|
285
|
+
props.selectedImageIndex ?? 0
|
|
286
|
+
);
|
|
287
|
+
const selectedImage = props.images[selectedImageIndex];
|
|
288
|
+
const colOffset = props.images.slice(0, selectedImageIndex).reduce((sum, image) => sum + image.columns, 0);
|
|
289
|
+
const rowOffset = props.images.slice(0, selectedImageIndex).reduce((sum, image) => sum + image.rows, 0);
|
|
290
|
+
const handleSelectImage = (index) => {
|
|
291
|
+
setSelectedImageIndex(index);
|
|
292
|
+
props.onSelectedImageIndexChange?.(index);
|
|
293
|
+
};
|
|
294
|
+
const handleCellClick = ({ row, col }) => {
|
|
295
|
+
const cellId = `${row},${col}`;
|
|
296
|
+
const next = new Set(selectedCells);
|
|
297
|
+
if (next.has(cellId)) {
|
|
298
|
+
next.delete(cellId);
|
|
299
|
+
} else {
|
|
300
|
+
next.add(cellId);
|
|
301
|
+
}
|
|
302
|
+
if (!isSelectionControlled) {
|
|
303
|
+
setInternalSelectedCells(next);
|
|
304
|
+
}
|
|
305
|
+
props.onSelectedCellsChange?.(next);
|
|
306
|
+
props.onCellClick?.({ row, col });
|
|
307
|
+
};
|
|
308
|
+
const renderSelector = props.renderImageSelector ?? ((selectorProps) => /* @__PURE__ */ jsx4(DefaultImageSelector, { ...selectorProps }));
|
|
309
|
+
return /* @__PURE__ */ jsxs2("div", { style: { position: "relative", width: props.width, height: props.height }, children: [
|
|
310
|
+
/* @__PURE__ */ jsx4(
|
|
311
|
+
ImageLayer,
|
|
312
|
+
{
|
|
313
|
+
image: selectedImage,
|
|
314
|
+
colOffset,
|
|
315
|
+
rowOffset,
|
|
316
|
+
width: props.width,
|
|
317
|
+
height: props.height,
|
|
318
|
+
alt: props.alt,
|
|
319
|
+
selectedCells,
|
|
320
|
+
maxBoundsPadding: props.maxBoundsPadding,
|
|
321
|
+
onCellClick: handleCellClick
|
|
322
|
+
},
|
|
323
|
+
selectedImage.src
|
|
324
|
+
),
|
|
325
|
+
renderSelector({
|
|
326
|
+
images: props.images,
|
|
327
|
+
selectedIndex: selectedImageIndex,
|
|
328
|
+
onSelect: handleSelectImage
|
|
329
|
+
})
|
|
330
|
+
] });
|
|
331
|
+
};
|
|
332
|
+
export {
|
|
333
|
+
MappedImage
|
|
334
|
+
};
|
|
335
|
+
//# sourceMappingURL=MappedImage.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../MappedImage.tsx","#style-inject:#style-inject","../MappedImage.css","../components/DefaultImageSelector.tsx","../components/ImageLayer.tsx","../components/Grid.tsx","../helpers/weights.ts","../helpers/headerIcon.ts","../helpers/cellAccessibility.ts","../helpers/imageBounds.ts","../helpers/colNames.ts"],"sourcesContent":["import { useState } from \"react\";\nimport \"leaflet/dist/leaflet.css\";\nimport \"./MappedImage.css\";\nimport type { ICellClickProps, ImageSelectorProps, MappedImageProps } from \"./types\";\nimport { DefaultImageSelector } from \"./components/DefaultImageSelector\";\nimport { ImageLayer } from \"./components/ImageLayer\";\n\nexport * from \"./types\";\n\nexport const MappedImage = (props: MappedImageProps) => {\n if (props.images.length === 0) {\n throw new Error(\"MappedImage requires at least one entry in `images`.\");\n }\n\n const invalidImage = props.images.find((image) => image.rows <= 0 || image.columns <= 0);\n if (invalidImage) {\n throw new Error(\n `MappedImage: image \"${invalidImage.name}\" must have rows > 0 and columns > 0.`,\n );\n }\n\n const [internalSelectedCells, setInternalSelectedCells] = useState<Set<string>>(\n props.selectedCells ?? new Set(),\n );\n const isSelectionControlled = props.selectedCells !== undefined;\n const selectedCells = isSelectionControlled ? props.selectedCells! : internalSelectedCells;\n\n const [selectedImageIndex, setSelectedImageIndex] = useState(\n props.selectedImageIndex ?? 0,\n );\n\n const selectedImage = props.images[selectedImageIndex];\n const colOffset = props.images\n .slice(0, selectedImageIndex)\n .reduce((sum, image) => sum + image.columns, 0);\n const rowOffset = props.images\n .slice(0, selectedImageIndex)\n .reduce((sum, image) => sum + image.rows, 0);\n\n const handleSelectImage = (index: number) => {\n setSelectedImageIndex(index);\n props.onSelectedImageIndexChange?.(index);\n };\n\n const handleCellClick = ({ row, col }: ICellClickProps) => {\n const cellId = `${row},${col}`;\n const next = new Set(selectedCells);\n if (next.has(cellId)) {\n next.delete(cellId);\n } else {\n next.add(cellId);\n }\n\n if (!isSelectionControlled) {\n setInternalSelectedCells(next);\n }\n props.onSelectedCellsChange?.(next);\n props.onCellClick?.({ row, col });\n };\n\n const renderSelector = props.renderImageSelector ?? (\n (selectorProps: ImageSelectorProps) => <DefaultImageSelector {...selectorProps} />\n );\n\n return (\n <div style={{ position: \"relative\", width: props.width, height: props.height }}>\n <ImageLayer\n key={selectedImage.src}\n image={selectedImage}\n colOffset={colOffset}\n rowOffset={rowOffset}\n width={props.width}\n height={props.height}\n alt={props.alt}\n selectedCells={selectedCells}\n maxBoundsPadding={props.maxBoundsPadding}\n onCellClick={handleCellClick}\n />\n {renderSelector({\n images: props.images,\n selectedIndex: selectedImageIndex,\n onSelect: handleSelectImage,\n })}\n </div>\n );\n};\n","\n export default function styleInject(css, { insertAt } = {}) {\n if (!css || typeof document === 'undefined') return\n \n const head = document.head || document.getElementsByTagName('head')[0]\n const style = document.createElement('style')\n style.type = 'text/css'\n \n if (insertAt === 'top') {\n if (head.firstChild) {\n head.insertBefore(style, head.firstChild)\n } else {\n head.appendChild(style)\n }\n } else {\n head.appendChild(style)\n }\n \n if (style.styleSheet) {\n style.styleSheet.cssText = css\n } else {\n style.appendChild(document.createTextNode(css))\n }\n }\n ","import styleInject from '#style-inject';styleInject(\".grid-header-label-icon {\\n background: none;\\n border: none;\\n}\\n.grid-header-label {\\n position: absolute;\\n display: block;\\n font-weight: bold;\\n white-space: nowrap;\\n}\\n.grid-header-label--column {\\n transform: translate(-50%, -100%);\\n}\\n.grid-header-label--row {\\n transform: translate(-100%, -50%);\\n}\\n\")","import type { ImageSelectorProps } from \"../types\";\n\nexport const DefaultImageSelector = ({ images, selectedIndex, onSelect }: ImageSelectorProps) => (\n <div\n style={{\n position: \"absolute\",\n top: 10,\n right: 10,\n zIndex: 1000,\n display: \"flex\",\n gap: 8,\n }}\n >\n {images.map((image, index) => (\n <button\n key={image.name}\n type=\"button\"\n onClick={() => onSelect(index)}\n style={{\n padding: \"12px 20px\",\n fontSize: 16,\n fontWeight: index === selectedIndex ? \"bold\" : \"normal\",\n background: index === selectedIndex ? \"#3388ff\" : \"white\",\n color: index === selectedIndex ? \"white\" : \"black\",\n border: \"1px solid #3388ff\",\n borderRadius: 6,\n }}\n >\n {image.name}\n </button>\n ))}\n </div>\n);\n","import { useEffect, useState } from \"react\";\nimport { CRS, type LatLngBoundsExpression, type LatLngTuple } from \"leaflet\";\nimport { MapContainer, ImageOverlay } from \"react-leaflet\";\nimport type { ImageLayerProps } from \"../types\";\nimport { Grid } from \"./Grid\";\nimport { imageBounds } from \"../helpers/imageBounds\";\nimport { colNames } from \"../helpers/colNames\";\nimport { resolveWeights } from \"../helpers/weights\";\n\nexport const ImageLayer = ({\n image,\n colOffset,\n rowOffset,\n width,\n height,\n alt,\n selectedCells,\n maxBoundsPadding = 100,\n onCellClick,\n}: ImageLayerProps) => {\n const [imgBounds, setImgBounds] = useState<LatLngTuple | null>(null);\n const [loadError, setLoadError] = useState<Error | null>(null);\n\n useEffect(() => {\n imageBounds(image.src, width, height).then(setImgBounds).catch(setLoadError);\n }, [image.src, width, height]);\n\n if (loadError) {\n throw loadError;\n }\n\n if (!imgBounds) {\n return null;\n }\n\n const [imgHeight, imgWidth] = imgBounds;\n const bounds: LatLngBoundsExpression = [[0, 0], imgBounds];\n const maxBounds: LatLngBoundsExpression = [\n [-maxBoundsPadding , -maxBoundsPadding],\n [imgHeight + maxBoundsPadding, imgWidth + maxBoundsPadding],\n ];\n const columnLabels = colNames(colOffset, image.columns);\n const columnWeights = resolveWeights(image.columns, image.columnWeights);\n const rowWeights = resolveWeights(image.rows, image.rowWeights);\n\n return (\n <MapContainer\n crs={CRS.Simple}\n bounds={bounds}\n boundsOptions={{ padding: [50, 50] }}\n maxBounds={maxBounds}\n maxBoundsViscosity={0.5}\n style={{ width, height }}\n maxZoom={2}\n >\n <Grid\n rows={image.rows}\n rowOffset={rowOffset}\n rowWeights={rowWeights}\n columnLabels={columnLabels}\n columnWeights={columnWeights}\n imgBounds={imgBounds}\n selectedCells={selectedCells}\n onCellClick={onCellClick}\n />\n <ImageOverlay url={image.src} bounds={bounds} alt={alt} />\n </MapContainer>\n );\n};\n","import { Rectangle, Marker } from \"react-leaflet\";\nimport type { LatLngBoundsExpression } from \"leaflet\";\nimport type { GridProps } from \"../types\";\nimport { weightOffsets } from \"../helpers/weights\";\nimport { headerIcon } from \"../helpers/headerIcon\";\nimport { bindCellAccessibility } from \"../helpers/cellAccessibility\";\n\nexport const Grid = ({\n rows,\n rowOffset,\n rowWeights,\n columnLabels,\n columnWeights,\n imgBounds,\n selectedCells,\n onCellClick,\n}: GridProps) => {\n const [height, width] = imgBounds;\n\n const colOffsets = weightOffsets(columnWeights);\n const pxPerColWeightUnit = width / colOffsets[colOffsets.length - 1];\n const colLeft = (colCount: number) => colOffsets[colCount] * pxPerColWeightUnit;\n const colRight = (colCount: number) => colOffsets[colCount + 1] * pxPerColWeightUnit;\n\n const rowOffsets = weightOffsets(rowWeights);\n const pxPerRowWeightUnit = height / rowOffsets[rowOffsets.length - 1];\n const rowBottom = (rowCount: number) => rowOffsets[rowCount] * pxPerRowWeightUnit;\n const rowTop = (rowCount: number) => rowOffsets[rowCount + 1] * pxPerRowWeightUnit;\n\n const cells = Array.from({ length: rows }).map((_, rowCount) =>\n columnLabels.map((columnLabel, colCount) => {\n const bounds: LatLngBoundsExpression = [\n [rowTop(rowCount), colRight(colCount)],\n [rowBottom(rowCount), colLeft(colCount)],\n ];\n\n const rowLabel = rowOffset + (rows - rowCount);\n const cellId = `${rowLabel},${columnLabel}`;\n const selected = selectedCells.has(cellId);\n const activate = () => onCellClick?.({ col: columnLabel, row: rowLabel });\n\n return (\n <Rectangle\n key={cellId}\n ref={(instance) =>\n bindCellAccessibility(instance, `${columnLabel}${rowLabel}`, selected, activate)\n }\n bounds={bounds}\n pathOptions={{\n fillColor: selected ? \"red\" : \"transparent\",\n fillOpacity: selected ? 0.5 : 0,\n weight: 1.5,\n }}\n eventHandlers={{\n click: activate,\n }}\n />\n );\n }),\n );\n\n const columnHeaders = columnLabels.map((columnLabel, colCount) => (\n <Marker\n key={`col-${columnLabel}`}\n position={[height, (colLeft(colCount) + colRight(colCount)) / 2]}\n icon={headerIcon(columnLabel, \"grid-header-label--column\")}\n interactive={false}\n />\n ));\n\n const rowHeaders = Array.from({ length: rows }).map((_, rowCount) => {\n const rowLabel = rowOffset + (rows - rowCount);\n return (\n <Marker\n key={`row-${rowLabel}`}\n position={[(rowBottom(rowCount) + rowTop(rowCount)) / 2, -7]}\n icon={headerIcon(String(rowLabel), \"grid-header-label--row\")}\n interactive={false}\n />\n );\n });\n\n return [...cells, columnHeaders, rowHeaders];\n};\n","export const resolveWeights = (\n count: number,\n overrides: Record<number, number> = {},\n): number[] => Array.from({ length: count }, (_, i) => overrides[i] ?? 1);\n\n/** Cumulative leading edge (in weight units) of each index, plus the total at the end. */\nexport const weightOffsets = (weights: number[]): number[] => {\n const offsets = [0];\n weights.forEach((weight) => offsets.push(offsets[offsets.length - 1] + weight));\n return offsets;\n};\n","import { divIcon } from \"leaflet\";\n\nexport const headerIcon = (label: string, className: string) =>\n divIcon({\n html: `<span class=\"grid-header-label ${className}\">${label}</span>`,\n className: \"grid-header-label-icon\",\n iconSize: [0, 0],\n });\n","import type { Rectangle as LeafletRectangle } from \"leaflet\";\n\n// Path.getElement() returns the underlying SVG node but isn't declared in @types/leaflet, hence the cast.\nconst getPathElement = (instance: LeafletRectangle) =>\n (instance as unknown as { getElement(): SVGElement | undefined }).getElement();\n\nexport const bindCellAccessibility = (\n instance: LeafletRectangle | null,\n label: string,\n selected: boolean,\n onActivate: () => void,\n) => {\n const el = instance && getPathElement(instance);\n if (!el) {\n return;\n }\n\n el.setAttribute(\"role\", \"button\");\n el.setAttribute(\"tabindex\", \"0\");\n el.setAttribute(\"aria-label\", label);\n el.setAttribute(\"aria-pressed\", String(selected));\n el.onkeydown = (e) => {\n if (e.key === \"Enter\" || e.key === \" \") {\n e.preventDefault();\n onActivate();\n }\n };\n};\n","import type { LatLngTuple } from \"leaflet\";\r\n\r\nexport const imageBounds = (src: string, containerWidth: number, containerHeight: number): Promise<LatLngTuple> =>\r\n new Promise((resolve, reject) => {\r\n const img = new Image();\r\n img.src = src;\r\n img.onload = () => {\r\n const scale = Math.min(containerWidth / img.width, containerHeight / img.height);\r\n resolve([img.height * scale, img.width * scale]);\r\n };\r\n img.onerror = () => reject(new Error(`Failed to load image: ${src}`));\r\n });","const toColumnLetter = (n: number): string => {\n let result = \"\";\n let remaining = n;\n\n while (remaining > 0) {\n const remainder = (remaining - 1) % 26;\n result = String.fromCharCode(65 + remainder) + result;\n remaining = Math.floor((remaining - 1) / 26);\n }\n\n return result;\n};\n\nexport const colNames = (startIndex: number, count: number): string[] =>\n Array.from({ length: count }, (_, i) => toColumnLetter(startIndex + i + 1));\n"],"mappings":"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","names":["useState","jsx","jsx","jsx","jsxs","useState"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mapped-image",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "A React component that overlays a clickable, weighted row/column grid on top of one or more images, built on react-leaflet.",
|
|
@@ -21,22 +21,32 @@
|
|
|
21
21
|
"url": "https://github.com/notetuwu/mapped-image/issues"
|
|
22
22
|
},
|
|
23
23
|
"type": "module",
|
|
24
|
-
"main": "MappedImage.
|
|
25
|
-
"
|
|
24
|
+
"main": "dist/MappedImage.js",
|
|
25
|
+
"module": "dist/MappedImage.js",
|
|
26
|
+
"types": "dist/MappedImage.d.ts",
|
|
27
|
+
"exports": {
|
|
28
|
+
".": {
|
|
29
|
+
"types": "./dist/MappedImage.d.ts",
|
|
30
|
+
"import": "./dist/MappedImage.js"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
26
33
|
"files": [
|
|
27
|
-
"
|
|
28
|
-
"**/*.tsx",
|
|
29
|
-
"**/*.css"
|
|
34
|
+
"dist"
|
|
30
35
|
],
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsup",
|
|
38
|
+
"prepare": "tsup"
|
|
39
|
+
},
|
|
31
40
|
"peerDependencies": {
|
|
32
41
|
"react": "^19.0.0",
|
|
33
42
|
"react-dom": "^19.0.0"
|
|
34
43
|
},
|
|
35
44
|
"dependencies": {
|
|
36
45
|
"leaflet": "^1.9.4",
|
|
37
|
-
"react-leaflet": "^5.0.0"
|
|
46
|
+
"react-leaflet": "^5.0.0",
|
|
47
|
+
"@types/leaflet": "^1.9.21"
|
|
38
48
|
},
|
|
39
49
|
"devDependencies": {
|
|
40
|
-
"
|
|
50
|
+
"tsup": "^8.3.5"
|
|
41
51
|
}
|
|
42
52
|
}
|
package/MappedImage.css
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
.grid-header-label-icon {
|
|
2
|
-
background: none;
|
|
3
|
-
border: none;
|
|
4
|
-
}
|
|
5
|
-
|
|
6
|
-
.grid-header-label {
|
|
7
|
-
position: absolute;
|
|
8
|
-
display: block;
|
|
9
|
-
font-weight: bold;
|
|
10
|
-
white-space: nowrap;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
.grid-header-label--column {
|
|
14
|
-
transform: translate(-50%, -100%);
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
.grid-header-label--row {
|
|
18
|
-
transform: translate(-100%, -50%);
|
|
19
|
-
}
|
package/MappedImage.tsx
DELETED
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
import { useState } from "react";
|
|
2
|
-
import "leaflet/dist/leaflet.css";
|
|
3
|
-
import "./MappedImage.css";
|
|
4
|
-
import type { ICellClickProps, ImageSelectorProps, MappedImageProps } from "./types";
|
|
5
|
-
import { DefaultImageSelector } from "./components/DefaultImageSelector";
|
|
6
|
-
import { ImageLayer } from "./components/ImageLayer";
|
|
7
|
-
|
|
8
|
-
export const MappedImage = (props: MappedImageProps) => {
|
|
9
|
-
if (props.images.length === 0) {
|
|
10
|
-
throw new Error("MappedImage requires at least one entry in `images`.");
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
const invalidImage = props.images.find((image) => image.rows <= 0 || image.columns <= 0);
|
|
14
|
-
if (invalidImage) {
|
|
15
|
-
throw new Error(
|
|
16
|
-
`MappedImage: image "${invalidImage.name}" must have rows > 0 and columns > 0.`,
|
|
17
|
-
);
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
const [internalSelectedCells, setInternalSelectedCells] = useState<Set<string>>(
|
|
21
|
-
props.selectedCells ?? new Set(),
|
|
22
|
-
);
|
|
23
|
-
const isSelectionControlled = props.selectedCells !== undefined;
|
|
24
|
-
const selectedCells = isSelectionControlled ? props.selectedCells! : internalSelectedCells;
|
|
25
|
-
|
|
26
|
-
const [selectedImageIndex, setSelectedImageIndex] = useState(
|
|
27
|
-
props.selectedImageIndex ?? 0,
|
|
28
|
-
);
|
|
29
|
-
|
|
30
|
-
const selectedImage = props.images[selectedImageIndex];
|
|
31
|
-
const colOffset = props.images
|
|
32
|
-
.slice(0, selectedImageIndex)
|
|
33
|
-
.reduce((sum, image) => sum + image.columns, 0);
|
|
34
|
-
const rowOffset = props.images
|
|
35
|
-
.slice(0, selectedImageIndex)
|
|
36
|
-
.reduce((sum, image) => sum + image.rows, 0);
|
|
37
|
-
|
|
38
|
-
const handleSelectImage = (index: number) => {
|
|
39
|
-
setSelectedImageIndex(index);
|
|
40
|
-
props.onSelectedImageIndexChange?.(index);
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
const handleCellClick = ({ row, col }: ICellClickProps) => {
|
|
44
|
-
const cellId = `${row},${col}`;
|
|
45
|
-
const next = new Set(selectedCells);
|
|
46
|
-
if (next.has(cellId)) {
|
|
47
|
-
next.delete(cellId);
|
|
48
|
-
} else {
|
|
49
|
-
next.add(cellId);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
if (!isSelectionControlled) {
|
|
53
|
-
setInternalSelectedCells(next);
|
|
54
|
-
}
|
|
55
|
-
props.onSelectedCellsChange?.(next);
|
|
56
|
-
props.onCellClick?.({ row, col });
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
const renderSelector = props.renderImageSelector ?? (
|
|
60
|
-
(selectorProps: ImageSelectorProps) => <DefaultImageSelector {...selectorProps} />
|
|
61
|
-
);
|
|
62
|
-
|
|
63
|
-
return (
|
|
64
|
-
<div style={{ position: "relative", width: props.width, height: props.height }}>
|
|
65
|
-
<ImageLayer
|
|
66
|
-
key={selectedImage.src}
|
|
67
|
-
image={selectedImage}
|
|
68
|
-
colOffset={colOffset}
|
|
69
|
-
rowOffset={rowOffset}
|
|
70
|
-
width={props.width}
|
|
71
|
-
height={props.height}
|
|
72
|
-
alt={props.alt}
|
|
73
|
-
selectedCells={selectedCells}
|
|
74
|
-
maxBoundsPadding={props.maxBoundsPadding}
|
|
75
|
-
onCellClick={handleCellClick}
|
|
76
|
-
/>
|
|
77
|
-
{renderSelector({
|
|
78
|
-
images: props.images,
|
|
79
|
-
selectedIndex: selectedImageIndex,
|
|
80
|
-
onSelect: handleSelectImage,
|
|
81
|
-
})}
|
|
82
|
-
</div>
|
|
83
|
-
);
|
|
84
|
-
};
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import type { ImageSelectorProps } from "../types";
|
|
2
|
-
|
|
3
|
-
export const DefaultImageSelector = ({ images, selectedIndex, onSelect }: ImageSelectorProps) => (
|
|
4
|
-
<div
|
|
5
|
-
style={{
|
|
6
|
-
position: "absolute",
|
|
7
|
-
top: 10,
|
|
8
|
-
right: 10,
|
|
9
|
-
zIndex: 1000,
|
|
10
|
-
display: "flex",
|
|
11
|
-
gap: 8,
|
|
12
|
-
}}
|
|
13
|
-
>
|
|
14
|
-
{images.map((image, index) => (
|
|
15
|
-
<button
|
|
16
|
-
key={image.name}
|
|
17
|
-
type="button"
|
|
18
|
-
onClick={() => onSelect(index)}
|
|
19
|
-
style={{
|
|
20
|
-
padding: "12px 20px",
|
|
21
|
-
fontSize: 16,
|
|
22
|
-
fontWeight: index === selectedIndex ? "bold" : "normal",
|
|
23
|
-
background: index === selectedIndex ? "#3388ff" : "white",
|
|
24
|
-
color: index === selectedIndex ? "white" : "black",
|
|
25
|
-
border: "1px solid #3388ff",
|
|
26
|
-
borderRadius: 6,
|
|
27
|
-
}}
|
|
28
|
-
>
|
|
29
|
-
{image.name}
|
|
30
|
-
</button>
|
|
31
|
-
))}
|
|
32
|
-
</div>
|
|
33
|
-
);
|
package/components/Grid.tsx
DELETED
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
import { Rectangle, Marker } from "react-leaflet";
|
|
2
|
-
import type { LatLngBoundsExpression } from "leaflet";
|
|
3
|
-
import type { GridProps } from "../types";
|
|
4
|
-
import { weightOffsets } from "../helpers/weights";
|
|
5
|
-
import { headerIcon } from "../helpers/headerIcon";
|
|
6
|
-
import { bindCellAccessibility } from "../helpers/cellAccessibility";
|
|
7
|
-
|
|
8
|
-
export const Grid = ({
|
|
9
|
-
rows,
|
|
10
|
-
rowOffset,
|
|
11
|
-
rowWeights,
|
|
12
|
-
columnLabels,
|
|
13
|
-
columnWeights,
|
|
14
|
-
imgBounds,
|
|
15
|
-
selectedCells,
|
|
16
|
-
onCellClick,
|
|
17
|
-
}: GridProps) => {
|
|
18
|
-
const [height, width] = imgBounds;
|
|
19
|
-
|
|
20
|
-
const colOffsets = weightOffsets(columnWeights);
|
|
21
|
-
const pxPerColWeightUnit = width / colOffsets[colOffsets.length - 1];
|
|
22
|
-
const colLeft = (colCount: number) => colOffsets[colCount] * pxPerColWeightUnit;
|
|
23
|
-
const colRight = (colCount: number) => colOffsets[colCount + 1] * pxPerColWeightUnit;
|
|
24
|
-
|
|
25
|
-
const rowOffsets = weightOffsets(rowWeights);
|
|
26
|
-
const pxPerRowWeightUnit = height / rowOffsets[rowOffsets.length - 1];
|
|
27
|
-
const rowBottom = (rowCount: number) => rowOffsets[rowCount] * pxPerRowWeightUnit;
|
|
28
|
-
const rowTop = (rowCount: number) => rowOffsets[rowCount + 1] * pxPerRowWeightUnit;
|
|
29
|
-
|
|
30
|
-
const cells = Array.from({ length: rows }).map((_, rowCount) =>
|
|
31
|
-
columnLabels.map((columnLabel, colCount) => {
|
|
32
|
-
const bounds: LatLngBoundsExpression = [
|
|
33
|
-
[rowTop(rowCount), colRight(colCount)],
|
|
34
|
-
[rowBottom(rowCount), colLeft(colCount)],
|
|
35
|
-
];
|
|
36
|
-
|
|
37
|
-
const rowLabel = rowOffset + (rows - rowCount);
|
|
38
|
-
const cellId = `${rowLabel},${columnLabel}`;
|
|
39
|
-
const selected = selectedCells.has(cellId);
|
|
40
|
-
const activate = () => onCellClick?.({ col: columnLabel, row: rowLabel });
|
|
41
|
-
|
|
42
|
-
return (
|
|
43
|
-
<Rectangle
|
|
44
|
-
key={cellId}
|
|
45
|
-
ref={(instance) =>
|
|
46
|
-
bindCellAccessibility(instance, `${columnLabel}${rowLabel}`, selected, activate)
|
|
47
|
-
}
|
|
48
|
-
bounds={bounds}
|
|
49
|
-
pathOptions={{
|
|
50
|
-
fillColor: selected ? "red" : "transparent",
|
|
51
|
-
fillOpacity: selected ? 0.5 : 0,
|
|
52
|
-
weight: 1.5,
|
|
53
|
-
}}
|
|
54
|
-
eventHandlers={{
|
|
55
|
-
click: activate,
|
|
56
|
-
}}
|
|
57
|
-
/>
|
|
58
|
-
);
|
|
59
|
-
}),
|
|
60
|
-
);
|
|
61
|
-
|
|
62
|
-
const columnHeaders = columnLabels.map((columnLabel, colCount) => (
|
|
63
|
-
<Marker
|
|
64
|
-
key={`col-${columnLabel}`}
|
|
65
|
-
position={[height, (colLeft(colCount) + colRight(colCount)) / 2]}
|
|
66
|
-
icon={headerIcon(columnLabel, "grid-header-label--column")}
|
|
67
|
-
interactive={false}
|
|
68
|
-
/>
|
|
69
|
-
));
|
|
70
|
-
|
|
71
|
-
const rowHeaders = Array.from({ length: rows }).map((_, rowCount) => {
|
|
72
|
-
const rowLabel = rowOffset + (rows - rowCount);
|
|
73
|
-
return (
|
|
74
|
-
<Marker
|
|
75
|
-
key={`row-${rowLabel}`}
|
|
76
|
-
position={[(rowBottom(rowCount) + rowTop(rowCount)) / 2, -7]}
|
|
77
|
-
icon={headerIcon(String(rowLabel), "grid-header-label--row")}
|
|
78
|
-
interactive={false}
|
|
79
|
-
/>
|
|
80
|
-
);
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
return [...cells, columnHeaders, rowHeaders];
|
|
84
|
-
};
|
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
import { useEffect, useState } from "react";
|
|
2
|
-
import { CRS, type LatLngBoundsExpression, type LatLngTuple } from "leaflet";
|
|
3
|
-
import { MapContainer, ImageOverlay } from "react-leaflet";
|
|
4
|
-
import type { ImageLayerProps } from "../types";
|
|
5
|
-
import { Grid } from "./Grid";
|
|
6
|
-
import { imageBounds } from "../helpers/imageBounds";
|
|
7
|
-
import { colNames } from "../helpers/colNames";
|
|
8
|
-
import { resolveWeights } from "../helpers/weights";
|
|
9
|
-
|
|
10
|
-
export const ImageLayer = ({
|
|
11
|
-
image,
|
|
12
|
-
colOffset,
|
|
13
|
-
rowOffset,
|
|
14
|
-
width,
|
|
15
|
-
height,
|
|
16
|
-
alt,
|
|
17
|
-
selectedCells,
|
|
18
|
-
maxBoundsPadding = 100,
|
|
19
|
-
onCellClick,
|
|
20
|
-
}: ImageLayerProps) => {
|
|
21
|
-
const [imgBounds, setImgBounds] = useState<LatLngTuple | null>(null);
|
|
22
|
-
const [loadError, setLoadError] = useState<Error | null>(null);
|
|
23
|
-
|
|
24
|
-
useEffect(() => {
|
|
25
|
-
imageBounds(image.src, width, height).then(setImgBounds).catch(setLoadError);
|
|
26
|
-
}, [image.src, width, height]);
|
|
27
|
-
|
|
28
|
-
if (loadError) {
|
|
29
|
-
throw loadError;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
if (!imgBounds) {
|
|
33
|
-
return null;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const [imgHeight, imgWidth] = imgBounds;
|
|
37
|
-
const bounds: LatLngBoundsExpression = [[0, 0], imgBounds];
|
|
38
|
-
const maxBounds: LatLngBoundsExpression = [
|
|
39
|
-
[-maxBoundsPadding , -maxBoundsPadding],
|
|
40
|
-
[imgHeight + maxBoundsPadding, imgWidth + maxBoundsPadding],
|
|
41
|
-
];
|
|
42
|
-
const columnLabels = colNames(colOffset, image.columns);
|
|
43
|
-
const columnWeights = resolveWeights(image.columns, image.columnWeights);
|
|
44
|
-
const rowWeights = resolveWeights(image.rows, image.rowWeights);
|
|
45
|
-
|
|
46
|
-
return (
|
|
47
|
-
<MapContainer
|
|
48
|
-
crs={CRS.Simple}
|
|
49
|
-
bounds={bounds}
|
|
50
|
-
boundsOptions={{ padding: [50, 50] }}
|
|
51
|
-
maxBounds={maxBounds}
|
|
52
|
-
maxBoundsViscosity={0.5}
|
|
53
|
-
style={{ width, height }}
|
|
54
|
-
maxZoom={2}
|
|
55
|
-
>
|
|
56
|
-
<Grid
|
|
57
|
-
rows={image.rows}
|
|
58
|
-
rowOffset={rowOffset}
|
|
59
|
-
rowWeights={rowWeights}
|
|
60
|
-
columnLabels={columnLabels}
|
|
61
|
-
columnWeights={columnWeights}
|
|
62
|
-
imgBounds={imgBounds}
|
|
63
|
-
selectedCells={selectedCells}
|
|
64
|
-
onCellClick={onCellClick}
|
|
65
|
-
/>
|
|
66
|
-
<ImageOverlay url={image.src} bounds={bounds} alt={alt} />
|
|
67
|
-
</MapContainer>
|
|
68
|
-
);
|
|
69
|
-
};
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
import type { Rectangle as LeafletRectangle } from "leaflet";
|
|
2
|
-
|
|
3
|
-
// Path.getElement() returns the underlying SVG node but isn't declared in @types/leaflet, hence the cast.
|
|
4
|
-
const getPathElement = (instance: LeafletRectangle) =>
|
|
5
|
-
(instance as unknown as { getElement(): SVGElement | undefined }).getElement();
|
|
6
|
-
|
|
7
|
-
export const bindCellAccessibility = (
|
|
8
|
-
instance: LeafletRectangle | null,
|
|
9
|
-
label: string,
|
|
10
|
-
selected: boolean,
|
|
11
|
-
onActivate: () => void,
|
|
12
|
-
) => {
|
|
13
|
-
const el = instance && getPathElement(instance);
|
|
14
|
-
if (!el) {
|
|
15
|
-
return;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
el.setAttribute("role", "button");
|
|
19
|
-
el.setAttribute("tabindex", "0");
|
|
20
|
-
el.setAttribute("aria-label", label);
|
|
21
|
-
el.setAttribute("aria-pressed", String(selected));
|
|
22
|
-
el.onkeydown = (e) => {
|
|
23
|
-
if (e.key === "Enter" || e.key === " ") {
|
|
24
|
-
e.preventDefault();
|
|
25
|
-
onActivate();
|
|
26
|
-
}
|
|
27
|
-
};
|
|
28
|
-
};
|
package/helpers/colNames.test.ts
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { colNames } from "./colNames";
|
|
3
|
-
|
|
4
|
-
describe("colNames", () => {
|
|
5
|
-
it("generates the first 26 letters starting from offset 0", () => {
|
|
6
|
-
expect(colNames(0, 3)).toEqual(["A", "B", "C"]);
|
|
7
|
-
});
|
|
8
|
-
|
|
9
|
-
it("wraps into double letters after Z", () => {
|
|
10
|
-
expect(colNames(24, 4)).toEqual(["Y", "Z", "AA", "AB"]);
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
it("continues from a non-zero offset, as used for multi-image column continuity", () => {
|
|
14
|
-
expect(colNames(5, 3)).toEqual(["F", "G", "H"]);
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
it("returns an empty array for a zero count", () => {
|
|
18
|
-
expect(colNames(0, 0)).toEqual([]);
|
|
19
|
-
});
|
|
20
|
-
});
|
package/helpers/colNames.ts
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
const toColumnLetter = (n: number): string => {
|
|
2
|
-
let result = "";
|
|
3
|
-
let remaining = n;
|
|
4
|
-
|
|
5
|
-
while (remaining > 0) {
|
|
6
|
-
const remainder = (remaining - 1) % 26;
|
|
7
|
-
result = String.fromCharCode(65 + remainder) + result;
|
|
8
|
-
remaining = Math.floor((remaining - 1) / 26);
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
return result;
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
export const colNames = (startIndex: number, count: number): string[] =>
|
|
15
|
-
Array.from({ length: count }, (_, i) => toColumnLetter(startIndex + i + 1));
|
package/helpers/headerIcon.ts
DELETED
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
-
import { imageBounds } from "./imageBounds";
|
|
3
|
-
|
|
4
|
-
class FakeImage {
|
|
5
|
-
width: number;
|
|
6
|
-
height: number;
|
|
7
|
-
shouldError: boolean;
|
|
8
|
-
onload: (() => void) | null = null;
|
|
9
|
-
onerror: (() => void) | null = null;
|
|
10
|
-
|
|
11
|
-
constructor(width: number, height: number, shouldError: boolean) {
|
|
12
|
-
this.width = width;
|
|
13
|
-
this.height = height;
|
|
14
|
-
this.shouldError = shouldError;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
set src(_value: string) {
|
|
18
|
-
queueMicrotask(() => {
|
|
19
|
-
if (this.shouldError) {
|
|
20
|
-
this.onerror?.();
|
|
21
|
-
} else {
|
|
22
|
-
this.onload?.();
|
|
23
|
-
}
|
|
24
|
-
});
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const stubImage = (width: number, height: number, shouldError = false) => {
|
|
29
|
-
vi.stubGlobal(
|
|
30
|
-
"Image",
|
|
31
|
-
class {
|
|
32
|
-
constructor() {
|
|
33
|
-
return new FakeImage(width, height, shouldError);
|
|
34
|
-
}
|
|
35
|
-
},
|
|
36
|
-
);
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
afterEach(() => {
|
|
40
|
-
vi.unstubAllGlobals();
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
describe("imageBounds", () => {
|
|
44
|
-
it("scales by the limiting dimension, preserving aspect ratio (width-constrained)", async () => {
|
|
45
|
-
stubImage(400, 200);
|
|
46
|
-
const bounds = await imageBounds("img.png", 100, 100);
|
|
47
|
-
expect(bounds).toEqual([50, 100]);
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it("scales by the limiting dimension, preserving aspect ratio (height-constrained)", async () => {
|
|
51
|
-
stubImage(200, 400);
|
|
52
|
-
const bounds = await imageBounds("img.png", 100, 100);
|
|
53
|
-
expect(bounds).toEqual([100, 50]);
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
it("rejects when the image fails to load", async () => {
|
|
57
|
-
stubImage(0, 0, true);
|
|
58
|
-
await expect(imageBounds("missing.png", 100, 100)).rejects.toThrow(
|
|
59
|
-
"Failed to load image: missing.png",
|
|
60
|
-
);
|
|
61
|
-
});
|
|
62
|
-
});
|
package/helpers/imageBounds.ts
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
import type { LatLngTuple } from "leaflet";
|
|
2
|
-
|
|
3
|
-
export const imageBounds = (src: string, containerWidth: number, containerHeight: number): Promise<LatLngTuple> =>
|
|
4
|
-
new Promise((resolve, reject) => {
|
|
5
|
-
const img = new Image();
|
|
6
|
-
img.src = src;
|
|
7
|
-
img.onload = () => {
|
|
8
|
-
const scale = Math.min(containerWidth / img.width, containerHeight / img.height);
|
|
9
|
-
resolve([img.height * scale, img.width * scale]);
|
|
10
|
-
};
|
|
11
|
-
img.onerror = () => reject(new Error(`Failed to load image: ${src}`));
|
|
12
|
-
});
|
package/helpers/weights.test.ts
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { resolveWeights, weightOffsets } from "./weights";
|
|
3
|
-
|
|
4
|
-
describe("resolveWeights", () => {
|
|
5
|
-
it("defaults every index to weight 1 with no overrides", () => {
|
|
6
|
-
expect(resolveWeights(4)).toEqual([1, 1, 1, 1]);
|
|
7
|
-
});
|
|
8
|
-
|
|
9
|
-
it("applies overrides only to the specified indices", () => {
|
|
10
|
-
expect(resolveWeights(4, { 2: 3 })).toEqual([1, 1, 3, 1]);
|
|
11
|
-
});
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
describe("weightOffsets", () => {
|
|
15
|
-
it("returns cumulative offsets starting at 0, ending at the total weight", () => {
|
|
16
|
-
expect(weightOffsets([1, 1, 3, 1])).toEqual([0, 1, 2, 5, 6]);
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
it("returns [0] for an empty weight list", () => {
|
|
20
|
-
expect(weightOffsets([])).toEqual([0]);
|
|
21
|
-
});
|
|
22
|
-
});
|
package/helpers/weights.ts
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
export const resolveWeights = (
|
|
2
|
-
count: number,
|
|
3
|
-
overrides: Record<number, number> = {},
|
|
4
|
-
): number[] => Array.from({ length: count }, (_, i) => overrides[i] ?? 1);
|
|
5
|
-
|
|
6
|
-
/** Cumulative leading edge (in weight units) of each index, plus the total at the end. */
|
|
7
|
-
export const weightOffsets = (weights: number[]): number[] => {
|
|
8
|
-
const offsets = [0];
|
|
9
|
-
weights.forEach((weight) => offsets.push(offsets[offsets.length - 1] + weight));
|
|
10
|
-
return offsets;
|
|
11
|
-
};
|