@weasel-js/loupe 1.8.0 → 1.9.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 +13 -0
- package/dist/index.d.ts +151 -3
- package/dist/index.js +151 -2
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -15,3 +15,16 @@ import { createLoupeModel } from '@weasel-js/loupe';
|
|
|
15
15
|
const loupe = createLoupeModel({ surface, factor: 8 });
|
|
16
16
|
loupe.aimAt({ x, y });
|
|
17
17
|
```
|
|
18
|
+
|
|
19
|
+
`createCanvasSource` makes any canvas — 2D or WebGL, yours or someone else's —
|
|
20
|
+
something a pixel lens can read. A WebGL canvas without `preserveDrawingBuffer`
|
|
21
|
+
is blank once composited, so its drawing code calls `capture()` in the same task
|
|
22
|
+
as the draw:
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
const source = createCanvasSource(gl, { requestRedraw: draw });
|
|
26
|
+
function draw() {
|
|
27
|
+
renderer.render(scene, camera);
|
|
28
|
+
source.capture(); // copies only while a lens is reading
|
|
29
|
+
}
|
|
30
|
+
```
|
package/dist/index.d.ts
CHANGED
|
@@ -5,6 +5,17 @@ interface LoupePoint {
|
|
|
5
5
|
x: number;
|
|
6
6
|
y: number;
|
|
7
7
|
}
|
|
8
|
+
/** How big a lens is, in CSS px: one number for a round or square lens, or a
|
|
9
|
+
* width and a height. */
|
|
10
|
+
type LoupeSize = number | {
|
|
11
|
+
width: number;
|
|
12
|
+
height: number;
|
|
13
|
+
};
|
|
14
|
+
/** `size` as a width and a height. */
|
|
15
|
+
declare function loupeExtent(size: LoupeSize): {
|
|
16
|
+
width: number;
|
|
17
|
+
height: number;
|
|
18
|
+
};
|
|
8
19
|
/** The lens' rectangle on that surface. */
|
|
9
20
|
interface LoupeRect extends LoupePoint {
|
|
10
21
|
w: number;
|
|
@@ -25,6 +36,33 @@ declare function loupeInnerView(target: LoupePoint, outer: View, rect: LoupeRect
|
|
|
25
36
|
* round trip — so this needs no `View`.
|
|
26
37
|
*/
|
|
27
38
|
declare function loupeSourcePoint(p: LoupePoint, rect: LoupeRect, aim: LoupePoint, factor: number): LoupePoint;
|
|
39
|
+
/** What {@link placeBand} fits. */
|
|
40
|
+
interface PlaceBandArgs {
|
|
41
|
+
/** The region to show whole, in host CSS px. */
|
|
42
|
+
band: LoupeRect;
|
|
43
|
+
/** The most it may be magnified: the lens's own factor. */
|
|
44
|
+
factor: number;
|
|
45
|
+
/** The host's width: the lens is never wider, and is moved to stay inside it. */
|
|
46
|
+
maxWidth: number;
|
|
47
|
+
/** The host's height, likewise. Omitted, the height is not limited. */
|
|
48
|
+
maxHeight?: number;
|
|
49
|
+
}
|
|
50
|
+
/** A lens fitted to a band, in the shape a loupe's `place` returns. */
|
|
51
|
+
interface BandPlacement {
|
|
52
|
+
/** Where the lens is drawn, moved as far as staying on the host needs. */
|
|
53
|
+
center: LoupePoint;
|
|
54
|
+
/** The band's center, which the lens shows at its middle wherever it is drawn. */
|
|
55
|
+
shows: LoupePoint;
|
|
56
|
+
width: number;
|
|
57
|
+
height: number;
|
|
58
|
+
factor: number;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* A lens showing all of `band`: magnified by `factor`, or by less where that
|
|
62
|
+
* would make the lens wider than `maxWidth` or taller than `maxHeight`, and
|
|
63
|
+
* drawn over the band except as far as it must move to stay on the host.
|
|
64
|
+
*/
|
|
65
|
+
declare function placeBand({ band, factor, maxWidth, maxHeight }: PlaceBandArgs): BandPlacement;
|
|
28
66
|
|
|
29
67
|
/** How a loupe magnifies. `'vector'` re-renders the source through a zoomed-in
|
|
30
68
|
* view, so content stays sharp at any factor; `'pixel'` blows up the actual
|
|
@@ -36,8 +74,16 @@ type LoupeMode = 'vector' | 'pixel';
|
|
|
36
74
|
* what colour is under a point, and whether anyone can still see it.
|
|
37
75
|
*/
|
|
38
76
|
interface LoupeSurface {
|
|
39
|
-
/**
|
|
40
|
-
|
|
77
|
+
/**
|
|
78
|
+
* The lens' rectangle, or `null` while it has none. A lens placed somewhere
|
|
79
|
+
* other than centered on the aim also says what it shows: `shows` is the
|
|
80
|
+
* surface point at its middle, and `factor` the magnification it shows it at.
|
|
81
|
+
* Either one omitted is the aim, or the model's own factor.
|
|
82
|
+
*/
|
|
83
|
+
lens(): (LoupeRect & {
|
|
84
|
+
shows?: LoupePoint;
|
|
85
|
+
factor?: number;
|
|
86
|
+
}) | null;
|
|
41
87
|
/**
|
|
42
88
|
* Does the lens itself cover this point? A stationary lens does over its own
|
|
43
89
|
* frame, which is what the freeze rule below is for; a lens that follows the
|
|
@@ -97,6 +143,9 @@ interface LoupeModel {
|
|
|
97
143
|
* `onPick`. Omit `p` to pick at the aim point. Leaves the aim, and `color`,
|
|
98
144
|
* alone. */
|
|
99
145
|
pick(p?: LoupePoint): string | null;
|
|
146
|
+
/** Sample the aim again, now: the pixels under it changed without it moving.
|
|
147
|
+
* Reports through `onColorChange` like an aim does. Ignored while hidden. */
|
|
148
|
+
resample(): void;
|
|
100
149
|
dispose(): void;
|
|
101
150
|
}
|
|
102
151
|
/**
|
|
@@ -105,4 +154,103 @@ interface LoupeModel {
|
|
|
105
154
|
*/
|
|
106
155
|
declare function createLoupeModel(opts: LoupeModelOptions): LoupeModel;
|
|
107
156
|
|
|
108
|
-
|
|
157
|
+
/** A canvas whose pixels a loupe can read. */
|
|
158
|
+
type SourceCanvas = HTMLCanvasElement | OffscreenCanvas;
|
|
159
|
+
/** A rendering context on a {@link SourceCanvas}. Passing one, rather than the
|
|
160
|
+
* bare canvas, is what tells a source whether the canvas keeps its pixels. */
|
|
161
|
+
type SourceContext = CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | WebGLRenderingContext | WebGL2RenderingContext | ImageBitmapRenderingContext;
|
|
162
|
+
/** Where a source canvas's pixels sit on the surface being magnified, in that
|
|
163
|
+
* surface's CSS px. The backing store is stretched over this box, so its
|
|
164
|
+
* width against the canvas's own `width` is the device-pixel ratio. */
|
|
165
|
+
interface SourceBox {
|
|
166
|
+
x: number;
|
|
167
|
+
y: number;
|
|
168
|
+
width: number;
|
|
169
|
+
height: number;
|
|
170
|
+
}
|
|
171
|
+
/** Options for {@link createCanvasSource}. */
|
|
172
|
+
interface CanvasSourceOptions {
|
|
173
|
+
/**
|
|
174
|
+
* Called when a reader starts and there is no current frame to read: draw
|
|
175
|
+
* the canvas again, and call `capture()` after it. Only needed by a canvas
|
|
176
|
+
* that is captured and does not redraw every frame on its own — without it
|
|
177
|
+
* such a lens stays empty until the next redraw.
|
|
178
|
+
*/
|
|
179
|
+
requestRedraw?: () => void;
|
|
180
|
+
/** Where the canvas sits on the magnified surface. Omitted, a reader works
|
|
181
|
+
* it out — a painter over the DOM measures the canvas's own box. */
|
|
182
|
+
box?: () => SourceBox | null;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Any canvas, as something a loupe reads pixels from: 2D, WebGL or WebGL2,
|
|
186
|
+
* on the page or offscreen, owned by whoever drew it.
|
|
187
|
+
*
|
|
188
|
+
* A WebGL canvas made without `preserveDrawingBuffer` is cleared once the
|
|
189
|
+
* browser composites it, so reading it from any later task — the lens's own
|
|
190
|
+
* frame — returns nothing. Such a source is *captured*: whoever draws the
|
|
191
|
+
* canvas calls {@link capture} right after drawing, in the same task, and the
|
|
192
|
+
* source copies the frame while it still exists. Every other canvas holds its
|
|
193
|
+
* pixels and is read directly.
|
|
194
|
+
*/
|
|
195
|
+
interface CanvasSource {
|
|
196
|
+
readonly canvas: SourceCanvas;
|
|
197
|
+
/**
|
|
198
|
+
* Whether frames reach readers only through {@link capture}. True for a
|
|
199
|
+
* source made from a WebGL context without `preserveDrawingBuffer`, and for
|
|
200
|
+
* any source once `capture()` has been called on it.
|
|
201
|
+
*/
|
|
202
|
+
readonly captured: boolean;
|
|
203
|
+
readonly box?: () => SourceBox | null;
|
|
204
|
+
/**
|
|
205
|
+
* Copy the frame just drawn. Call it in the same task as the draw — right
|
|
206
|
+
* after `gl.drawArrays`, `renderer.render(...)` or whatever finished the
|
|
207
|
+
* frame. Free while nothing is reading: it copies only while a reader holds
|
|
208
|
+
* the source.
|
|
209
|
+
*/
|
|
210
|
+
capture(): void;
|
|
211
|
+
/** The pixels to read now: the last capture, or the canvas itself when it
|
|
212
|
+
* keeps its own. `null` while a captured source has no current frame. */
|
|
213
|
+
frame(): CanvasImageSource | null;
|
|
214
|
+
/** Hex color of the backing-store pixel at `p` (device px, not CSS px), or
|
|
215
|
+
* `null` where it is transparent, off the canvas, or there is no frame. */
|
|
216
|
+
sample(p: LoupePoint): string | null;
|
|
217
|
+
/** Start reading. While any reader holds the source, `capture` copies;
|
|
218
|
+
* returns the release. */
|
|
219
|
+
retain(): () => void;
|
|
220
|
+
/** Run `fn` after each capture lands; returns an unsubscribe. */
|
|
221
|
+
subscribeFrame(fn: () => void): () => void;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Make a {@link CanvasSource} for a canvas, or for a context on one.
|
|
225
|
+
*
|
|
226
|
+
* Pass the context when there is one: a WebGL context without
|
|
227
|
+
* `preserveDrawingBuffer` is then known to need capturing before anything has
|
|
228
|
+
* gone blank. A bare canvas is read directly until `capture()` is first
|
|
229
|
+
* called on it. The source never calls `getContext` on the canvas it reads —
|
|
230
|
+
* on a canvas with no context yet, that would create one with the wrong
|
|
231
|
+
* attributes and leave whoever draws it unable to get theirs.
|
|
232
|
+
*/
|
|
233
|
+
declare function createCanvasSource(target: SourceCanvas | SourceContext, options?: CanvasSourceOptions): CanvasSource;
|
|
234
|
+
/**
|
|
235
|
+
* The rectangle of `source`'s backing store a lens of `size` CSS px
|
|
236
|
+
* magnifying `factor` times about `aim` shows, in device px. `box` is where
|
|
237
|
+
* the canvas sits in the same CSS px as `aim`; the scale between the two is
|
|
238
|
+
* measured per axis, so a canvas stretched unevenly is read correctly too.
|
|
239
|
+
*/
|
|
240
|
+
declare function sourceRegion(aim: LoupePoint, factor: number, size: LoupeSize, box: SourceBox, backing: {
|
|
241
|
+
width: number;
|
|
242
|
+
height: number;
|
|
243
|
+
}): {
|
|
244
|
+
sx: number;
|
|
245
|
+
sy: number;
|
|
246
|
+
sw: number;
|
|
247
|
+
sh: number;
|
|
248
|
+
};
|
|
249
|
+
/** A surface point in CSS px, as the backing-store pixel of a canvas laid
|
|
250
|
+
* over `box` that it lands on. */
|
|
251
|
+
declare function sourcePixel(p: LoupePoint, box: SourceBox, backing: {
|
|
252
|
+
width: number;
|
|
253
|
+
height: number;
|
|
254
|
+
}): LoupePoint;
|
|
255
|
+
|
|
256
|
+
export { type BandPlacement, type CanvasSource, type CanvasSourceOptions, type LoupeMode, type LoupeModel, type LoupeModelOptions, type LoupePoint, type LoupeRect, type LoupeSize, type LoupeSurface, type PlaceBandArgs, type SourceBox, type SourceCanvas, type SourceContext, createCanvasSource, createLoupeModel, loupeExtent, loupeInnerView, loupeSourcePoint, placeBand, sourcePixel, sourceRegion };
|
package/dist/index.js
CHANGED
|
@@ -3,6 +3,9 @@ import { normalizeZoom } from '@weasel-js/core';
|
|
|
3
3
|
// src/model.ts
|
|
4
4
|
|
|
5
5
|
// src/geometry.ts
|
|
6
|
+
function loupeExtent(size) {
|
|
7
|
+
return typeof size === "number" ? { width: size, height: size } : size;
|
|
8
|
+
}
|
|
6
9
|
function loupeInnerView(target, outer, rect, factor) {
|
|
7
10
|
const scale = { x: outer.scale.x * factor, y: outer.scale.y * factor };
|
|
8
11
|
return {
|
|
@@ -17,6 +20,25 @@ function loupeSourcePoint(p, rect, aim, factor) {
|
|
|
17
20
|
y: aim.y + (p.y - (rect.y + rect.h / 2)) / factor
|
|
18
21
|
};
|
|
19
22
|
}
|
|
23
|
+
function placeBand({ band, factor, maxWidth, maxHeight }) {
|
|
24
|
+
let fit = factor;
|
|
25
|
+
if (band.w > 0) fit = Math.min(fit, maxWidth / band.w);
|
|
26
|
+
if (maxHeight !== void 0 && band.h > 0) fit = Math.min(fit, maxHeight / band.h);
|
|
27
|
+
const width = band.w * fit;
|
|
28
|
+
const height = band.h * fit;
|
|
29
|
+
const shows = { x: band.x + band.w / 2, y: band.y + band.h / 2 };
|
|
30
|
+
const within = (c, half, max) => Math.min(Math.max(c, half), Math.max(half, max - half));
|
|
31
|
+
return {
|
|
32
|
+
center: {
|
|
33
|
+
x: within(shows.x, width / 2, maxWidth),
|
|
34
|
+
y: maxHeight === void 0 ? shows.y : within(shows.y, height / 2, maxHeight)
|
|
35
|
+
},
|
|
36
|
+
shows,
|
|
37
|
+
width,
|
|
38
|
+
height,
|
|
39
|
+
factor: fit
|
|
40
|
+
};
|
|
41
|
+
}
|
|
20
42
|
|
|
21
43
|
// src/model.ts
|
|
22
44
|
function createLoupeModel(opts) {
|
|
@@ -90,17 +112,144 @@ function createLoupeModel(opts) {
|
|
|
90
112
|
if (p) {
|
|
91
113
|
const rect = surface.lens();
|
|
92
114
|
if (!rect) return null;
|
|
93
|
-
at = loupeSourcePoint(p, rect, aim, factor);
|
|
115
|
+
at = loupeSourcePoint(p, rect, rect.shows ?? aim, rect.factor ?? factor);
|
|
94
116
|
if (surface.covers(at)) return null;
|
|
95
117
|
}
|
|
96
118
|
const hex = surface.sample(at);
|
|
97
119
|
if (hex !== null) opts.onPick?.(hex);
|
|
98
120
|
return hex;
|
|
99
121
|
},
|
|
122
|
+
resample() {
|
|
123
|
+
if (disposed || surface.hidden()) return;
|
|
124
|
+
aimUnsampled = false;
|
|
125
|
+
sampleColor();
|
|
126
|
+
},
|
|
100
127
|
dispose: teardown
|
|
101
128
|
};
|
|
102
129
|
}
|
|
103
130
|
|
|
104
|
-
|
|
131
|
+
// src/canvasSource.ts
|
|
132
|
+
var MISSED_FRAMES_BEFORE_WARNING = 120;
|
|
133
|
+
function isWebGL(ctx) {
|
|
134
|
+
return "drawArrays" in ctx;
|
|
135
|
+
}
|
|
136
|
+
function isContext(target) {
|
|
137
|
+
return "canvas" in target && !("getContext" in target);
|
|
138
|
+
}
|
|
139
|
+
function makeCanvas(width, height) {
|
|
140
|
+
if (typeof OffscreenCanvas !== "undefined") return new OffscreenCanvas(width, height);
|
|
141
|
+
const c = document.createElement("canvas");
|
|
142
|
+
c.width = width;
|
|
143
|
+
c.height = height;
|
|
144
|
+
return c;
|
|
145
|
+
}
|
|
146
|
+
function context2d(canvas, willReadFrequently = false) {
|
|
147
|
+
return canvas.getContext("2d", { willReadFrequently });
|
|
148
|
+
}
|
|
149
|
+
function createCanvasSource(target, options = {}) {
|
|
150
|
+
const canvas = isContext(target) ? target.canvas : target;
|
|
151
|
+
const mustCapture = isContext(target) && isWebGL(target) && !target.getContextAttributes()?.preserveDrawingBuffer;
|
|
152
|
+
let captured = mustCapture;
|
|
153
|
+
let readers = 0;
|
|
154
|
+
let snapshot = null;
|
|
155
|
+
let current = false;
|
|
156
|
+
let missed = 0;
|
|
157
|
+
let warned = false;
|
|
158
|
+
let probe = null;
|
|
159
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
160
|
+
const source = {
|
|
161
|
+
canvas,
|
|
162
|
+
get captured() {
|
|
163
|
+
return captured;
|
|
164
|
+
},
|
|
165
|
+
box: options.box,
|
|
166
|
+
capture() {
|
|
167
|
+
captured = true;
|
|
168
|
+
if (readers === 0) return;
|
|
169
|
+
const { width, height } = canvas;
|
|
170
|
+
if (width === 0 || height === 0) return;
|
|
171
|
+
if (!snapshot) snapshot = makeCanvas(width, height);
|
|
172
|
+
else if (snapshot.width !== width || snapshot.height !== height) {
|
|
173
|
+
snapshot.width = width;
|
|
174
|
+
snapshot.height = height;
|
|
175
|
+
}
|
|
176
|
+
const ctx = context2d(snapshot);
|
|
177
|
+
if (!ctx) return;
|
|
178
|
+
ctx.globalCompositeOperation = "copy";
|
|
179
|
+
ctx.drawImage(canvas, 0, 0);
|
|
180
|
+
current = true;
|
|
181
|
+
missed = 0;
|
|
182
|
+
for (const fn of listeners) fn();
|
|
183
|
+
},
|
|
184
|
+
frame() {
|
|
185
|
+
if (!captured) return canvas.width > 0 && canvas.height > 0 ? canvas : null;
|
|
186
|
+
if (current && snapshot) return snapshot;
|
|
187
|
+
if (!warned && ++missed >= MISSED_FRAMES_BEFORE_WARNING) {
|
|
188
|
+
warned = true;
|
|
189
|
+
console.warn(
|
|
190
|
+
"[weasel loupe] A WebGL canvas without preserveDrawingBuffer is being read, and nothing has called capture() on its source. Call source.capture() right after drawing each frame, or pass requestRedraw if the canvas draws on demand."
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
return null;
|
|
194
|
+
},
|
|
195
|
+
sample(p) {
|
|
196
|
+
const frame = source.frame();
|
|
197
|
+
if (!frame) return null;
|
|
198
|
+
const x = Math.floor(p.x);
|
|
199
|
+
const y = Math.floor(p.y);
|
|
200
|
+
if (x < 0 || y < 0 || x >= canvas.width || y >= canvas.height) return null;
|
|
201
|
+
if (!probe) {
|
|
202
|
+
const c = makeCanvas(1, 1);
|
|
203
|
+
probe = context2d(c, true);
|
|
204
|
+
if (!probe) return null;
|
|
205
|
+
}
|
|
206
|
+
probe.globalCompositeOperation = "copy";
|
|
207
|
+
probe.drawImage(frame, x, y, 1, 1, 0, 0, 1, 1);
|
|
208
|
+
const [r, g, b, a] = probe.getImageData(0, 0, 1, 1).data;
|
|
209
|
+
if (a === 0) return null;
|
|
210
|
+
return `#${[r, g, b].map((c) => c.toString(16).padStart(2, "0")).join("")}`;
|
|
211
|
+
},
|
|
212
|
+
retain() {
|
|
213
|
+
readers++;
|
|
214
|
+
if (readers === 1 && captured) {
|
|
215
|
+
current = false;
|
|
216
|
+
options.requestRedraw?.();
|
|
217
|
+
}
|
|
218
|
+
let held = true;
|
|
219
|
+
return () => {
|
|
220
|
+
if (!held) return;
|
|
221
|
+
held = false;
|
|
222
|
+
readers--;
|
|
223
|
+
};
|
|
224
|
+
},
|
|
225
|
+
subscribeFrame(fn) {
|
|
226
|
+
listeners.add(fn);
|
|
227
|
+
return () => {
|
|
228
|
+
listeners.delete(fn);
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
return source;
|
|
233
|
+
}
|
|
234
|
+
function sourceRegion(aim, factor, size, box, backing) {
|
|
235
|
+
const { width, height } = loupeExtent(size);
|
|
236
|
+
const spanX = width / factor;
|
|
237
|
+
const spanY = height / factor;
|
|
238
|
+
const kx = box.width > 0 ? backing.width / box.width : 1;
|
|
239
|
+
const ky = box.height > 0 ? backing.height / box.height : 1;
|
|
240
|
+
return {
|
|
241
|
+
sx: (aim.x - spanX / 2 - box.x) * kx,
|
|
242
|
+
sy: (aim.y - spanY / 2 - box.y) * ky,
|
|
243
|
+
sw: spanX * kx,
|
|
244
|
+
sh: spanY * ky
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
function sourcePixel(p, box, backing) {
|
|
248
|
+
const kx = box.width > 0 ? backing.width / box.width : 1;
|
|
249
|
+
const ky = box.height > 0 ? backing.height / box.height : 1;
|
|
250
|
+
return { x: (p.x - box.x) * kx, y: (p.y - box.y) * ky };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export { createCanvasSource, createLoupeModel, loupeExtent, loupeInnerView, loupeSourcePoint, placeBand, sourcePixel, sourceRegion };
|
|
105
254
|
//# sourceMappingURL=index.js.map
|
|
106
255
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/geometry.ts","../src/model.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["import type { View } from '@weasel-js/core';\n\n/** A point on the magnified surface, in its own CSS pixels. */\nexport interface LoupePoint {\n x: number;\n y: number;\n}\n\n/** The lens' rectangle on that surface. */\nexport interface LoupeRect extends LoupePoint {\n w: number;\n h: number;\n}\n\n/**\n * The inner `View` a loupe renders its content through: the outer view's\n * magnification times `factor`, positioned so `target` sits at the center of\n * a viewport the size of `rect`.\n */\nexport function loupeInnerView(\n target: LoupePoint,\n outer: View,\n rect: LoupeRect,\n factor: number,\n): View {\n const scale = { x: outer.scale.x * factor, y: outer.scale.y * factor };\n return {\n x: target.x - rect.w / 2 / scale.x,\n y: target.y - rect.h / 2 / scale.y,\n scale,\n };\n}\n\n/**\n * Where on the outer surface a point inside the lens is looking, in\n * screen-space CSS px.\n *\n * The inverse of {@link loupeInnerView}: the lens centers `aim` in `rect` and\n * magnifies by `factor`, and the outer view's own scale cancels out of the\n * round trip — so this needs no `View`.\n */\nexport function loupeSourcePoint(\n p: LoupePoint,\n rect: LoupeRect,\n aim: LoupePoint,\n factor: number,\n): LoupePoint {\n return {\n x: aim.x + (p.x - (rect.x + rect.w / 2)) / factor,\n y: aim.y + (p.y - (rect.y + rect.h / 2)) / factor,\n };\n}\n","import { normalizeZoom } from '@weasel-js/core';\nimport { type LoupePoint, type LoupeRect, loupeSourcePoint } from './geometry';\n\n/** How a loupe magnifies. `'vector'` re-renders the source through a zoomed-in\n * view, so content stays sharp at any factor; `'pixel'` blows up the actual\n * pixels the surface presented. */\nexport type LoupeMode = 'vector' | 'pixel';\n\n/**\n * What a loupe needs from the surface it magnifies. A painter implements it,\n * and the model asks nothing about how the lens is drawn — only where it is,\n * what colour is under a point, and whether anyone can still see it.\n */\nexport interface LoupeSurface {\n /** The lens' rectangle, or `null` while it has none. */\n lens(): LoupeRect | null;\n /**\n * Does the lens itself cover this point? A stationary lens does over its own\n * frame, which is what the freeze rule below is for; a lens that follows the\n * pointer never covers it and answers `false`.\n */\n covers(p: LoupePoint): boolean;\n /** Hex colour at a surface point, or `null` when the surface cannot say. */\n sample(p: LoupePoint): string | null;\n /** Is the lens off screen? Aims are ignored while it is. */\n hidden(): boolean;\n /** Is the lens gone for good? The model tears itself down when it is. */\n gone(): boolean;\n /** Something the painter must redraw for has changed. */\n changed(): void;\n /**\n * Run `fn` after each frame the surface lands; returns an unsubscribe.\n * Implement it when `sample` reads pixels that only show an aim once a\n * frame has painted — a framebuffer read back. The model then samples the\n * aim on the next landed frame, so `color` settles a frame after the aim.\n * Omit it when `sample` can answer for a fresh aim right away.\n */\n subscribeFrame?(fn: () => void): () => void;\n}\n\n/** Options for {@link createLoupeModel}. */\nexport interface LoupeModelOptions {\n surface: LoupeSurface;\n mode?: LoupeMode;\n /** Magnification. Default 8. Always positive and finite: a factor that is\n * not becomes `ZOOM_FLOOR`, the same rule a view's zoom follows. */\n factor?: number;\n /** Bounds `setFactor` clamps to. Unset means bounded only by that rule. */\n minFactor?: number;\n maxFactor?: number;\n /** Called with the hex colour under the aim point whenever it changes. */\n onColorChange?: (hex: string) => void;\n /** Called when a pick lands — an eyedropper on the magnified surface. Where\n * the colour goes is the consumer's business. */\n onPick?: (hex: string) => void;\n /** Called when the model tears itself down because the surface reported the\n * lens gone, so the painter can drop what it was holding. */\n onDispose?: () => void;\n}\n\n/** Where a loupe is aimed, how far it magnifies, and what colour it is over. */\nexport interface LoupeModel {\n readonly mode: LoupeMode;\n readonly factor: number;\n /** Last aimed point, in the surface's CSS px. */\n readonly aim: LoupePoint;\n /** Hex colour under the aim point. `null` until something answers. */\n readonly color: string | null;\n setMode(mode: LoupeMode): void;\n setFactor(factor: number): void;\n /** Aim at a surface point. Ignored while the lens covers it, is hidden, or\n * the model is disposed. */\n aimAt(p: LoupePoint): void;\n /** Sample what the lens shows at `p`, a point inside it, and report that to\n * `onPick`. Omit `p` to pick at the aim point. Leaves the aim, and `color`,\n * alone. */\n pick(p?: LoupePoint): string | null;\n dispose(): void;\n}\n\n/**\n * Build the loupe's model over a surface. It holds the state a magnifier has —\n * aim, factor, mode, colour — and none of the drawing.\n */\nexport function createLoupeModel(opts: LoupeModelOptions): LoupeModel {\n const { surface } = opts;\n let mode: LoupeMode = opts.mode ?? 'vector';\n let factor = normalizeZoom(opts.factor ?? 8);\n let aim: LoupePoint = { x: 0, y: 0 };\n let color: string | null = null;\n let disposed = false;\n let aimUnsampled = false;\n\n const clamp = (n: number): number =>\n normalizeZoom(Math.min(opts.maxFactor ?? Number.POSITIVE_INFINITY,\n Math.max(opts.minFactor ?? Number.NEGATIVE_INFINITY, n)));\n\n const sampleColor = () => {\n const hex = surface.sample(aim);\n if (hex === null || hex === color) return;\n color = hex;\n opts.onColorChange?.(hex);\n };\n\n const unsubscribeFrame = surface.subscribeFrame?.(() => {\n if (!aimUnsampled) return;\n aimUnsampled = false;\n sampleColor();\n });\n\n const teardown = () => {\n if (disposed) return;\n disposed = true;\n unsubscribeFrame?.();\n opts.onDispose?.();\n };\n\n return {\n get mode() { return mode; },\n get factor() { return factor; },\n get aim() { return aim; },\n get color() { return color; },\n\n setMode(next) {\n if (disposed || next === mode) return;\n mode = next;\n surface.changed();\n },\n\n setFactor(next) {\n if (disposed) return;\n factor = clamp(next);\n surface.changed();\n },\n\n aimAt(p) {\n if (disposed) return;\n // A lens can be taken away by whatever owns it, without coming back\n // through `dispose`. Follow it down rather than keep sampling for a\n // magnifier nobody can see.\n if (surface.gone()) { teardown(); return; }\n if (surface.hidden()) return;\n if (surface.covers(p)) return;\n aim = p;\n if (unsubscribeFrame) aimUnsampled = true;\n else sampleColor();\n surface.changed();\n },\n\n pick(p) {\n if (disposed) return null;\n let at = aim;\n if (p) {\n const rect = surface.lens();\n if (!rect) return null;\n at = loupeSourcePoint(p, rect, aim, factor);\n // The lens is part of the picture the surface presents. A point near\n // its frame maps back underneath it, and sampling there reports the\n // lens' own chrome as artwork.\n if (surface.covers(at)) return null;\n }\n const hex = surface.sample(at);\n if (hex !== null) opts.onPick?.(hex);\n return hex;\n },\n\n dispose: teardown,\n };\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/geometry.ts","../src/model.ts","../src/canvasSource.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["import type { View } from '@weasel-js/core';\n\n/** A point on the magnified surface, in its own CSS pixels. */\nexport interface LoupePoint {\n x: number;\n y: number;\n}\n\n/** How big a lens is, in CSS px: one number for a round or square lens, or a\n * width and a height. */\nexport type LoupeSize = number | { width: number; height: number };\n\n/** `size` as a width and a height. */\nexport function loupeExtent(size: LoupeSize): { width: number; height: number } {\n return typeof size === 'number' ? { width: size, height: size } : size;\n}\n\n/** The lens' rectangle on that surface. */\nexport interface LoupeRect extends LoupePoint {\n w: number;\n h: number;\n}\n\n/**\n * The inner `View` a loupe renders its content through: the outer view's\n * magnification times `factor`, positioned so `target` sits at the center of\n * a viewport the size of `rect`.\n */\nexport function loupeInnerView(\n target: LoupePoint,\n outer: View,\n rect: LoupeRect,\n factor: number,\n): View {\n const scale = { x: outer.scale.x * factor, y: outer.scale.y * factor };\n return {\n x: target.x - rect.w / 2 / scale.x,\n y: target.y - rect.h / 2 / scale.y,\n scale,\n };\n}\n\n/**\n * Where on the outer surface a point inside the lens is looking, in\n * screen-space CSS px.\n *\n * The inverse of {@link loupeInnerView}: the lens centers `aim` in `rect` and\n * magnifies by `factor`, and the outer view's own scale cancels out of the\n * round trip — so this needs no `View`.\n */\nexport function loupeSourcePoint(\n p: LoupePoint,\n rect: LoupeRect,\n aim: LoupePoint,\n factor: number,\n): LoupePoint {\n return {\n x: aim.x + (p.x - (rect.x + rect.w / 2)) / factor,\n y: aim.y + (p.y - (rect.y + rect.h / 2)) / factor,\n };\n}\n\n/** What {@link placeBand} fits. */\nexport interface PlaceBandArgs {\n /** The region to show whole, in host CSS px. */\n band: LoupeRect;\n /** The most it may be magnified: the lens's own factor. */\n factor: number;\n /** The host's width: the lens is never wider, and is moved to stay inside it. */\n maxWidth: number;\n /** The host's height, likewise. Omitted, the height is not limited. */\n maxHeight?: number;\n}\n\n/** A lens fitted to a band, in the shape a loupe's `place` returns. */\nexport interface BandPlacement {\n /** Where the lens is drawn, moved as far as staying on the host needs. */\n center: LoupePoint;\n /** The band's center, which the lens shows at its middle wherever it is drawn. */\n shows: LoupePoint;\n width: number;\n height: number;\n factor: number;\n}\n\n/**\n * A lens showing all of `band`: magnified by `factor`, or by less where that\n * would make the lens wider than `maxWidth` or taller than `maxHeight`, and\n * drawn over the band except as far as it must move to stay on the host.\n */\nexport function placeBand({ band, factor, maxWidth, maxHeight }: PlaceBandArgs): BandPlacement {\n let fit = factor;\n if (band.w > 0) fit = Math.min(fit, maxWidth / band.w);\n if (maxHeight !== undefined && band.h > 0) fit = Math.min(fit, maxHeight / band.h);\n const width = band.w * fit;\n const height = band.h * fit;\n const shows = { x: band.x + band.w / 2, y: band.y + band.h / 2 };\n const within = (c: number, half: number, max: number): number =>\n Math.min(Math.max(c, half), Math.max(half, max - half));\n return {\n center: {\n x: within(shows.x, width / 2, maxWidth),\n y: maxHeight === undefined ? shows.y : within(shows.y, height / 2, maxHeight),\n },\n shows,\n width,\n height,\n factor: fit,\n };\n}\n","import { normalizeZoom } from '@weasel-js/core';\nimport { type LoupePoint, type LoupeRect, loupeSourcePoint } from './geometry';\n\n/** How a loupe magnifies. `'vector'` re-renders the source through a zoomed-in\n * view, so content stays sharp at any factor; `'pixel'` blows up the actual\n * pixels the surface presented. */\nexport type LoupeMode = 'vector' | 'pixel';\n\n/**\n * What a loupe needs from the surface it magnifies. A painter implements it,\n * and the model asks nothing about how the lens is drawn — only where it is,\n * what colour is under a point, and whether anyone can still see it.\n */\nexport interface LoupeSurface {\n /**\n * The lens' rectangle, or `null` while it has none. A lens placed somewhere\n * other than centered on the aim also says what it shows: `shows` is the\n * surface point at its middle, and `factor` the magnification it shows it at.\n * Either one omitted is the aim, or the model's own factor.\n */\n lens(): (LoupeRect & { shows?: LoupePoint; factor?: number }) | null;\n /**\n * Does the lens itself cover this point? A stationary lens does over its own\n * frame, which is what the freeze rule below is for; a lens that follows the\n * pointer never covers it and answers `false`.\n */\n covers(p: LoupePoint): boolean;\n /** Hex colour at a surface point, or `null` when the surface cannot say. */\n sample(p: LoupePoint): string | null;\n /** Is the lens off screen? Aims are ignored while it is. */\n hidden(): boolean;\n /** Is the lens gone for good? The model tears itself down when it is. */\n gone(): boolean;\n /** Something the painter must redraw for has changed. */\n changed(): void;\n /**\n * Run `fn` after each frame the surface lands; returns an unsubscribe.\n * Implement it when `sample` reads pixels that only show an aim once a\n * frame has painted — a framebuffer read back. The model then samples the\n * aim on the next landed frame, so `color` settles a frame after the aim.\n * Omit it when `sample` can answer for a fresh aim right away.\n */\n subscribeFrame?(fn: () => void): () => void;\n}\n\n/** Options for {@link createLoupeModel}. */\nexport interface LoupeModelOptions {\n surface: LoupeSurface;\n mode?: LoupeMode;\n /** Magnification. Default 8. Always positive and finite: a factor that is\n * not becomes `ZOOM_FLOOR`, the same rule a view's zoom follows. */\n factor?: number;\n /** Bounds `setFactor` clamps to. Unset means bounded only by that rule. */\n minFactor?: number;\n maxFactor?: number;\n /** Called with the hex colour under the aim point whenever it changes. */\n onColorChange?: (hex: string) => void;\n /** Called when a pick lands — an eyedropper on the magnified surface. Where\n * the colour goes is the consumer's business. */\n onPick?: (hex: string) => void;\n /** Called when the model tears itself down because the surface reported the\n * lens gone, so the painter can drop what it was holding. */\n onDispose?: () => void;\n}\n\n/** Where a loupe is aimed, how far it magnifies, and what colour it is over. */\nexport interface LoupeModel {\n readonly mode: LoupeMode;\n readonly factor: number;\n /** Last aimed point, in the surface's CSS px. */\n readonly aim: LoupePoint;\n /** Hex colour under the aim point. `null` until something answers. */\n readonly color: string | null;\n setMode(mode: LoupeMode): void;\n setFactor(factor: number): void;\n /** Aim at a surface point. Ignored while the lens covers it, is hidden, or\n * the model is disposed. */\n aimAt(p: LoupePoint): void;\n /** Sample what the lens shows at `p`, a point inside it, and report that to\n * `onPick`. Omit `p` to pick at the aim point. Leaves the aim, and `color`,\n * alone. */\n pick(p?: LoupePoint): string | null;\n /** Sample the aim again, now: the pixels under it changed without it moving.\n * Reports through `onColorChange` like an aim does. Ignored while hidden. */\n resample(): void;\n dispose(): void;\n}\n\n/**\n * Build the loupe's model over a surface. It holds the state a magnifier has —\n * aim, factor, mode, colour — and none of the drawing.\n */\nexport function createLoupeModel(opts: LoupeModelOptions): LoupeModel {\n const { surface } = opts;\n let mode: LoupeMode = opts.mode ?? 'vector';\n let factor = normalizeZoom(opts.factor ?? 8);\n let aim: LoupePoint = { x: 0, y: 0 };\n let color: string | null = null;\n let disposed = false;\n let aimUnsampled = false;\n\n const clamp = (n: number): number =>\n normalizeZoom(Math.min(opts.maxFactor ?? Number.POSITIVE_INFINITY,\n Math.max(opts.minFactor ?? Number.NEGATIVE_INFINITY, n)));\n\n const sampleColor = () => {\n const hex = surface.sample(aim);\n if (hex === null || hex === color) return;\n color = hex;\n opts.onColorChange?.(hex);\n };\n\n const unsubscribeFrame = surface.subscribeFrame?.(() => {\n if (!aimUnsampled) return;\n aimUnsampled = false;\n sampleColor();\n });\n\n const teardown = () => {\n if (disposed) return;\n disposed = true;\n unsubscribeFrame?.();\n opts.onDispose?.();\n };\n\n return {\n get mode() { return mode; },\n get factor() { return factor; },\n get aim() { return aim; },\n get color() { return color; },\n\n setMode(next) {\n if (disposed || next === mode) return;\n mode = next;\n surface.changed();\n },\n\n setFactor(next) {\n if (disposed) return;\n factor = clamp(next);\n surface.changed();\n },\n\n aimAt(p) {\n if (disposed) return;\n // A lens can be taken away by whatever owns it, without coming back\n // through `dispose`. Follow it down rather than keep sampling for a\n // magnifier nobody can see.\n if (surface.gone()) { teardown(); return; }\n if (surface.hidden()) return;\n if (surface.covers(p)) return;\n aim = p;\n if (unsubscribeFrame) aimUnsampled = true;\n else sampleColor();\n surface.changed();\n },\n\n pick(p) {\n if (disposed) return null;\n let at = aim;\n if (p) {\n const rect = surface.lens();\n if (!rect) return null;\n at = loupeSourcePoint(p, rect, rect.shows ?? aim, rect.factor ?? factor);\n // The lens is part of the picture the surface presents. A point near\n // its frame maps back underneath it, and sampling there reports the\n // lens' own chrome as artwork.\n if (surface.covers(at)) return null;\n }\n const hex = surface.sample(at);\n if (hex !== null) opts.onPick?.(hex);\n return hex;\n },\n\n resample() {\n if (disposed || surface.hidden()) return;\n aimUnsampled = false;\n sampleColor();\n },\n\n dispose: teardown,\n };\n}\n","import { type LoupePoint, type LoupeSize, loupeExtent } from './geometry';\n\n/** A canvas whose pixels a loupe can read. */\nexport type SourceCanvas = HTMLCanvasElement | OffscreenCanvas;\n\n/** A rendering context on a {@link SourceCanvas}. Passing one, rather than the\n * bare canvas, is what tells a source whether the canvas keeps its pixels. */\nexport type SourceContext =\n | CanvasRenderingContext2D\n | OffscreenCanvasRenderingContext2D\n | WebGLRenderingContext\n | WebGL2RenderingContext\n | ImageBitmapRenderingContext;\n\n/** Where a source canvas's pixels sit on the surface being magnified, in that\n * surface's CSS px. The backing store is stretched over this box, so its\n * width against the canvas's own `width` is the device-pixel ratio. */\nexport interface SourceBox {\n x: number;\n y: number;\n width: number;\n height: number;\n}\n\n/** Options for {@link createCanvasSource}. */\nexport interface CanvasSourceOptions {\n /**\n * Called when a reader starts and there is no current frame to read: draw\n * the canvas again, and call `capture()` after it. Only needed by a canvas\n * that is captured and does not redraw every frame on its own — without it\n * such a lens stays empty until the next redraw.\n */\n requestRedraw?: () => void;\n /** Where the canvas sits on the magnified surface. Omitted, a reader works\n * it out — a painter over the DOM measures the canvas's own box. */\n box?: () => SourceBox | null;\n}\n\n/**\n * Any canvas, as something a loupe reads pixels from: 2D, WebGL or WebGL2,\n * on the page or offscreen, owned by whoever drew it.\n *\n * A WebGL canvas made without `preserveDrawingBuffer` is cleared once the\n * browser composites it, so reading it from any later task — the lens's own\n * frame — returns nothing. Such a source is *captured*: whoever draws the\n * canvas calls {@link capture} right after drawing, in the same task, and the\n * source copies the frame while it still exists. Every other canvas holds its\n * pixels and is read directly.\n */\nexport interface CanvasSource {\n readonly canvas: SourceCanvas;\n /**\n * Whether frames reach readers only through {@link capture}. True for a\n * source made from a WebGL context without `preserveDrawingBuffer`, and for\n * any source once `capture()` has been called on it.\n */\n readonly captured: boolean;\n readonly box?: () => SourceBox | null;\n /**\n * Copy the frame just drawn. Call it in the same task as the draw — right\n * after `gl.drawArrays`, `renderer.render(...)` or whatever finished the\n * frame. Free while nothing is reading: it copies only while a reader holds\n * the source.\n */\n capture(): void;\n /** The pixels to read now: the last capture, or the canvas itself when it\n * keeps its own. `null` while a captured source has no current frame. */\n frame(): CanvasImageSource | null;\n /** Hex color of the backing-store pixel at `p` (device px, not CSS px), or\n * `null` where it is transparent, off the canvas, or there is no frame. */\n sample(p: LoupePoint): string | null;\n /** Start reading. While any reader holds the source, `capture` copies;\n * returns the release. */\n retain(): () => void;\n /** Run `fn` after each capture lands; returns an unsubscribe. */\n subscribeFrame(fn: () => void): () => void;\n}\n\n/** A frame read this many times with no capture behind it means the drawing\n * code is not calling `capture()`. About two seconds of lens frames. */\nconst MISSED_FRAMES_BEFORE_WARNING = 120;\n\nfunction isWebGL(ctx: SourceContext): ctx is WebGLRenderingContext | WebGL2RenderingContext {\n return 'drawArrays' in ctx;\n}\n\nfunction isContext(target: SourceCanvas | SourceContext): target is SourceContext {\n return 'canvas' in target && !('getContext' in target);\n}\n\nfunction makeCanvas(width: number, height: number): SourceCanvas {\n if (typeof OffscreenCanvas !== 'undefined') return new OffscreenCanvas(width, height);\n const c = document.createElement('canvas');\n c.width = width;\n c.height = height;\n return c;\n}\n\ntype Ctx2D = CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;\n\nfunction context2d(canvas: SourceCanvas, willReadFrequently = false): Ctx2D | null {\n return canvas.getContext('2d', { willReadFrequently }) as Ctx2D | null;\n}\n\n/**\n * Make a {@link CanvasSource} for a canvas, or for a context on one.\n *\n * Pass the context when there is one: a WebGL context without\n * `preserveDrawingBuffer` is then known to need capturing before anything has\n * gone blank. A bare canvas is read directly until `capture()` is first\n * called on it. The source never calls `getContext` on the canvas it reads —\n * on a canvas with no context yet, that would create one with the wrong\n * attributes and leave whoever draws it unable to get theirs.\n */\nexport function createCanvasSource(\n target: SourceCanvas | SourceContext,\n options: CanvasSourceOptions = {},\n): CanvasSource {\n const canvas = (isContext(target) ? target.canvas : target) as SourceCanvas;\n const mustCapture =\n isContext(target) && isWebGL(target) && !target.getContextAttributes()?.preserveDrawingBuffer;\n\n let captured = mustCapture;\n let readers = 0;\n let snapshot: SourceCanvas | null = null;\n let current = false;\n let missed = 0;\n let warned = false;\n let probe: Ctx2D | null = null;\n const listeners = new Set<() => void>();\n\n const source: CanvasSource = {\n canvas,\n get captured() {\n return captured;\n },\n box: options.box,\n\n capture() {\n captured = true;\n if (readers === 0) return;\n const { width, height } = canvas;\n if (width === 0 || height === 0) return;\n if (!snapshot) snapshot = makeCanvas(width, height);\n else if (snapshot.width !== width || snapshot.height !== height) {\n snapshot.width = width;\n snapshot.height = height;\n }\n const ctx = context2d(snapshot);\n if (!ctx) return;\n ctx.globalCompositeOperation = 'copy';\n ctx.drawImage(canvas, 0, 0);\n current = true;\n missed = 0;\n for (const fn of listeners) fn();\n },\n\n frame() {\n if (!captured) return canvas.width > 0 && canvas.height > 0 ? canvas : null;\n if (current && snapshot) return snapshot;\n if (!warned && ++missed >= MISSED_FRAMES_BEFORE_WARNING) {\n warned = true;\n console.warn(\n '[weasel loupe] A WebGL canvas without preserveDrawingBuffer is being read, ' +\n 'and nothing has called capture() on its source. Call source.capture() right ' +\n 'after drawing each frame, or pass requestRedraw if the canvas draws on demand.',\n );\n }\n return null;\n },\n\n sample(p) {\n const frame = source.frame();\n if (!frame) return null;\n const x = Math.floor(p.x);\n const y = Math.floor(p.y);\n if (x < 0 || y < 0 || x >= canvas.width || y >= canvas.height) return null;\n if (!probe) {\n const c = makeCanvas(1, 1);\n probe = context2d(c, true);\n if (!probe) return null;\n }\n probe.globalCompositeOperation = 'copy';\n probe.drawImage(frame, x, y, 1, 1, 0, 0, 1, 1);\n const [r, g, b, a] = probe.getImageData(0, 0, 1, 1).data;\n if (a === 0) return null;\n return `#${[r, g, b].map((c) => c.toString(16).padStart(2, '0')).join('')}`;\n },\n\n retain() {\n readers++;\n if (readers === 1 && captured) {\n // Whatever was copied before the last reader left may be long stale.\n current = false;\n options.requestRedraw?.();\n }\n let held = true;\n return () => {\n if (!held) return;\n held = false;\n readers--;\n };\n },\n\n subscribeFrame(fn) {\n listeners.add(fn);\n return () => {\n listeners.delete(fn);\n };\n },\n };\n return source;\n}\n\n/**\n * The rectangle of `source`'s backing store a lens of `size` CSS px\n * magnifying `factor` times about `aim` shows, in device px. `box` is where\n * the canvas sits in the same CSS px as `aim`; the scale between the two is\n * measured per axis, so a canvas stretched unevenly is read correctly too.\n */\nexport function sourceRegion(\n aim: LoupePoint,\n factor: number,\n size: LoupeSize,\n box: SourceBox,\n backing: { width: number; height: number },\n): { sx: number; sy: number; sw: number; sh: number } {\n const { width, height } = loupeExtent(size);\n const spanX = width / factor;\n const spanY = height / factor;\n const kx = box.width > 0 ? backing.width / box.width : 1;\n const ky = box.height > 0 ? backing.height / box.height : 1;\n return {\n sx: (aim.x - spanX / 2 - box.x) * kx,\n sy: (aim.y - spanY / 2 - box.y) * ky,\n sw: spanX * kx,\n sh: spanY * ky,\n };\n}\n\n/** A surface point in CSS px, as the backing-store pixel of a canvas laid\n * over `box` that it lands on. */\nexport function sourcePixel(\n p: LoupePoint,\n box: SourceBox,\n backing: { width: number; height: number },\n): LoupePoint {\n const kx = box.width > 0 ? backing.width / box.width : 1;\n const ky = box.height > 0 ? backing.height / box.height : 1;\n return { x: (p.x - box.x) * kx, y: (p.y - box.y) * ky };\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@weasel-js/loupe",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.0",
|
|
4
4
|
"description": "Surface-free magnifier model: where a loupe is aimed, how far it magnifies, and what colour it is over. Painters live with the surfaces they draw on.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"node": ">=22"
|
|
30
30
|
},
|
|
31
31
|
"peerDependencies": {
|
|
32
|
-
"@weasel-js/core": "1.
|
|
32
|
+
"@weasel-js/core": "1.9.0"
|
|
33
33
|
},
|
|
34
34
|
"files": [
|
|
35
35
|
"dist",
|