mapped-image 0.1.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.
@@ -0,0 +1,84 @@
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
+ };
package/README.md ADDED
@@ -0,0 +1,75 @@
1
+ # mapped-image
2
+
3
+ A React component that overlays a clickable row/column grid on top of one or more images, built on [react-leaflet](https://react-leaflet.js.org/). Useful for marking specific regions of a part photographed from multiple angles, where rows and columns continue numbering across angles since they represent the same physical surface.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install github:notetuwu/mapped-image
9
+ ```
10
+
11
+ `leaflet` and `react-leaflet` are declared as dependencies and installed automatically. `react` and `react-dom` are peer dependencies, resolved from your app.
12
+
13
+ > **Note:** this package currently ships untranspiled `.tsx` source (no build step) and is intended to be installed straight from this repository's source. Your app's own bundler is expected to compile it. If you instead need a pre-built `dist` (ESM/CJS + type declarations) for non-TS-aware consumers, that's not set up yet.
14
+
15
+ ## Usage
16
+
17
+ ```tsx
18
+ import { MappedImage } from "mapped-image/MappedImage";
19
+
20
+ <MappedImage
21
+ images={[
22
+ { name: "Front", src: "/front.png", columns: 5, rows: 5 },
23
+ { name: "Side", src: "/side.png", columns: 3, rows: 5 },
24
+ ]}
25
+ alt="Engine block"
26
+ width={800}
27
+ height={800}
28
+ onCellClick={({ row, col }) => console.log(row, col)}
29
+ onSelectedCellsChange={(cells) => console.log(cells)}
30
+ />
31
+ ```
32
+
33
+ Clicking a cell toggles its selection (filled red) and reports the cell's row number / column letter. Both axes are numbered continuously across `images` — e.g. the "Side" image above picks up at column `F` (since "Front" used `A`-`E`) and would pick up at row `6` if "Front" had 5 rows. The same letter+number pair always identifies the same physical location, regardless of which image happens to be selected — useful since all images represent different angles of the same physical part.
34
+
35
+ ## Props
36
+
37
+ | Prop | Type | Description |
38
+ | --- | --- | --- |
39
+ | `images` | `ImageConfig[]` | Required. The set of selectable angle images — see below. |
40
+ | `alt` | `string` | Alt text for the currently displayed image. |
41
+ | `width` / `height` | `number` | Pixel size of the rendered component. The image is scaled to fit without distortion (letterboxed if its aspect ratio doesn't match). |
42
+ | `selectedCells` | `Set<string>` | Selection, as `"<row>,<colLetter>"` ids (e.g. `"3,F"`). If provided, the component is **fully controlled**: it's your responsibility to update it (typically in `onSelectedCellsChange`) for clicks to visibly toggle. Omit it to let the component manage selection internally instead. |
43
+ | `selectedImageIndex` | `number` | Optional initial selected image index. Uncontrolled after mount. |
44
+ | `onCellClick` | `({ row, col }) => void` | Fires on every cell click, `row` is a 1-based number from the top, `col` is the letter label. |
45
+ | `onSelectedCellsChange` | `(cells: Set<string>) => void` | Fires whenever a cell is toggled, with the resulting selection. Required if `selectedCells` is controlled — also useful for persisting/exporting the selection (e.g. from an external editor). |
46
+ | `onSelectedImageIndexChange` | `(index: number) => void` | Fires whenever the active image changes. |
47
+ | `renderImageSelector` | `(props: ImageSelectorProps) => ReactNode` | Optional override for the image-switcher UI rendered over the map. Defaults to a row of buttons; pass your own to fully customize it, or build your own selector entirely outside this component using the controlled `selectedImageIndex`/`onSelectedImageIndexChange` props instead. |
48
+ | `maxBoundsPadding` | `number` | Extra pannable margin, in pixels, beyond the image edges. Defaults to `50`. |
49
+
50
+ ### `ImageConfig`
51
+
52
+ | Field | Type | Description |
53
+ | --- | --- | --- |
54
+ | `name` | `string` | Label shown in the default image selector. |
55
+ | `src` | `string` | Image URL. |
56
+ | `columns` | `number` | Number of columns in this image's grid. |
57
+ | `rows` | `number` | Number of rows in this image's grid. |
58
+ | `columnWeights` | `Record<number, number>` | Optional relative width overrides, keyed by 0-based column index (default weight is `1`). Only needs entries for the columns that aren't uniform width — e.g. `{ 2: 3 }` makes column index 2 three times as wide as the others. |
59
+ | `rowWeights` | `Record<number, number>` | Same as `columnWeights`, but for row heights. |
60
+
61
+ > Both `rows`/`columns` must be greater than `0`.
62
+
63
+ ## Building an editor
64
+
65
+ This package intentionally exposes `selectedImageIndex`/`onSelectedImageIndexChange` and a controllable `selectedCells`/`onSelectedCellsChange` so a separate editor app (outside this package) can drive the grid entirely from its own state — e.g. to add/remove rows and columns, adjust `columnWeights`/`rowWeights`, and persist the resulting `ImageConfig[]` plus selection as a JSON config to reload later. There's no editor UI in this package itself.
66
+
67
+ ## Development
68
+
69
+ ```bash
70
+ npm install
71
+ npm run dev # demo app in src/App.tsx
72
+ npm test # unit tests
73
+ npm run lint
74
+ npm run build # typecheck + build the demo app
75
+ ```
@@ -0,0 +1,33 @@
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
+ );
@@ -0,0 +1,84 @@
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
+ };
@@ -0,0 +1,69 @@
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
+ };
@@ -0,0 +1,28 @@
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
+ };
@@ -0,0 +1,20 @@
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
+ });
@@ -0,0 +1,15 @@
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));
@@ -0,0 +1,8 @@
1
+ import { divIcon } from "leaflet";
2
+
3
+ export const headerIcon = (label: string, className: string) =>
4
+ divIcon({
5
+ html: `<span class="grid-header-label ${className}">${label}</span>`,
6
+ className: "grid-header-label-icon",
7
+ iconSize: [0, 0],
8
+ });
@@ -0,0 +1,62 @@
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
+ });
@@ -0,0 +1,12 @@
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
+ });
@@ -0,0 +1,22 @@
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
+ });
@@ -0,0 +1,11 @@
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
+ };
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "mapped-image",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "license": "MIT",
6
+ "description": "A React component that overlays a clickable, weighted row/column grid on top of one or more images, built on react-leaflet.",
7
+ "keywords": [
8
+ "react",
9
+ "leaflet",
10
+ "react-leaflet",
11
+ "image",
12
+ "grid",
13
+ "overlay"
14
+ ],
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/notetuwu/mapped-image.git"
18
+ },
19
+ "homepage": "https://github.com/notetuwu/mapped-image#readme",
20
+ "bugs": {
21
+ "url": "https://github.com/notetuwu/mapped-image/issues"
22
+ },
23
+ "type": "module",
24
+ "main": "MappedImage.tsx",
25
+ "types": "MappedImage.tsx",
26
+ "files": [
27
+ "**/*.ts",
28
+ "**/*.tsx"
29
+ ],
30
+ "peerDependencies": {
31
+ "react": "^19.0.0",
32
+ "react-dom": "^19.0.0"
33
+ },
34
+ "dependencies": {
35
+ "leaflet": "^1.9.4",
36
+ "react-leaflet": "^5.0.0"
37
+ },
38
+ "devDependencies": {
39
+ "@types/leaflet": "^1.9.21"
40
+ }
41
+ }
package/types.ts ADDED
@@ -0,0 +1,62 @@
1
+ import type { LatLngTuple } from "leaflet";
2
+ import type { ReactNode } from "react";
3
+
4
+ export type ImageConfig = {
5
+ name: string;
6
+ src: string;
7
+ columns: number;
8
+ rows: number;
9
+ /** colIndex -> relative width weight, default weight is 1. Only needs entries for non-uniform columns. */
10
+ columnWeights?: Record<number, number>;
11
+ /** rowIndex -> relative height weight, default weight is 1. Only needs entries for non-uniform rows. */
12
+ rowWeights?: Record<number, number>;
13
+ };
14
+
15
+ export type ImageSelectorProps = {
16
+ images: ImageConfig[];
17
+ selectedIndex: number;
18
+ onSelect: (index: number) => void;
19
+ };
20
+
21
+ export type MappedImageProps = {
22
+ images: ImageConfig[];
23
+ alt: string;
24
+ width: number;
25
+ height: number;
26
+ selectedCells?: Set<string>;
27
+ selectedImageIndex?: number;
28
+ onCellClick?: (props: ICellClickProps) => void;
29
+ onSelectedCellsChange?: (selectedCells: Set<string>) => void;
30
+ onSelectedImageIndexChange?: (index: number) => void;
31
+ renderImageSelector?: (props: ImageSelectorProps) => ReactNode;
32
+ /** Extra pannable margin (in pixels) beyond the image edges. Defaults to 50. */
33
+ maxBoundsPadding?: number;
34
+ };
35
+
36
+ export type ImageLayerProps = {
37
+ image: ImageConfig;
38
+ colOffset: number;
39
+ rowOffset: number;
40
+ width: number;
41
+ height: number;
42
+ alt: string;
43
+ selectedCells: Set<string>;
44
+ maxBoundsPadding?: number;
45
+ onCellClick: GridProps["onCellClick"];
46
+ };
47
+
48
+ export type GridProps = {
49
+ rows: number;
50
+ rowOffset: number;
51
+ rowWeights: number[];
52
+ columnLabels: string[];
53
+ columnWeights: number[];
54
+ imgBounds: LatLngTuple;
55
+ selectedCells: Set<string>;
56
+ onCellClick?: (props: ICellClickProps) => void;
57
+ };
58
+
59
+ export interface ICellClickProps {
60
+ col: string;
61
+ row: number;
62
+ }