feat(editor): F4.3 the editor's frame and surface; a switch's cloud read and written
packages/editor (React 18, Vite) draws a document as Java FILIUS does: Java's default hardware icons, the device button of JSidebarButton, cables as JCablePanel's quadratic curve between the buttons' middles, wireless cables dashed, documentation items below. The surface moves with mouse and finger (the pointer captured only once it moved) and zooms with Ctrl and the wheel, a trackpad pinch, two fingers and the zoom bar; the first view shows all. German and English catalogs with Java's device names, the dashboard's colour tokens, the bridge with its origin check. packages/doc gets the drawing's light view of a document. Java's Switch.cloud (the Internet in the examples) was lost on reading; it is now read, kept in the model and written, with FlsOracle and the corpus, written and exported checks renewed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
// The surface (plan §7.1): the devices with Java's icons and labels, the
|
||||
// cables, the documentation items, in an SVG that one moves with a finger or
|
||||
// the mouse and zooms with two fingers, a pinch on the trackpad, Ctrl and the
|
||||
// wheel or the buttons.
|
||||
//
|
||||
// The pointer is captured only once it has moved: captured at once, it would
|
||||
// take the click from whatever lies under it (pointer-capture trap; jsdom
|
||||
// never shows it, Playwright does). The surface sets touch-action: none, so
|
||||
// that the browser's own zoom and scroll stay out of a gesture on it (M7).
|
||||
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
|
||||
import type { DocumentationView, NodeView, TopologyView } from '@mdg-filius/doc';
|
||||
import { useTranslate } from '../i18n/i18n.js';
|
||||
import { ICON_URLS } from './icons.js';
|
||||
import { bounds, cablePath, deviceBox, JAVA_LABEL_FONT, type DeviceBox } from './layout.js';
|
||||
import { fitted, middle, shifted, spread, zoomedAt, zoomIn, zoomOut, type Point, type Size, type Viewport } from './viewport.js';
|
||||
import { ZoomBar } from './ZoomBar.js';
|
||||
|
||||
/** How far a pointer moves before a press becomes a drag. */
|
||||
const DRAG_THRESHOLD = 4;
|
||||
|
||||
/** The label's width in Java's font, measured once per text. */
|
||||
function useTextWidth(): (text: string) => number {
|
||||
const cache = useRef(new Map<string, number>());
|
||||
const context = useMemo(() => document.createElement('canvas').getContext('2d'), []);
|
||||
return useCallback(
|
||||
(text: string) => {
|
||||
const known = cache.current.get(text);
|
||||
if (known !== undefined) return known;
|
||||
if (context === null) return text.length * 7;
|
||||
context.font = JAVA_LABEL_FONT;
|
||||
const width = context.measureText(text).width;
|
||||
cache.current.set(text, width);
|
||||
return width;
|
||||
},
|
||||
[context],
|
||||
);
|
||||
}
|
||||
|
||||
const rgba = (color: DocumentationView['color']) =>
|
||||
`rgba(${Math.round(color.red * 255)}, ${Math.round(color.green * 255)}, ${Math.round(color.blue * 255)}, ${color.alpha})`;
|
||||
|
||||
function Documentation({ item }: { readonly item: DocumentationView }) {
|
||||
if (item.kind === 'rectangle') {
|
||||
return <rect className="doc-rectangle" x={item.x} y={item.y} width={item.width} height={item.height} fill={rgba(item.color)} data-doc={item.id} />;
|
||||
}
|
||||
return (
|
||||
<foreignObject x={item.x} y={item.y} width={item.width} height={item.height} data-doc={item.id}>
|
||||
<div
|
||||
style={{
|
||||
color: rgba(item.color),
|
||||
font: `${item.bold ? 'bold ' : ''}${item.size}px "${item.font}", Dialog, sans-serif`,
|
||||
whiteSpace: 'pre-wrap',
|
||||
overflow: 'hidden',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
{item.text}
|
||||
</div>
|
||||
</foreignObject>
|
||||
);
|
||||
}
|
||||
|
||||
function Device({ node, box, kindName }: { readonly node: NodeView; readonly box: DeviceBox; readonly kindName: string }) {
|
||||
return (
|
||||
<g className="device" data-node={node.id} data-box={`${box.x} ${box.y} ${box.width} ${box.height}`} role="img" aria-label={`${kindName}: ${node.label}`}>
|
||||
<image href={ICON_URLS[box.icon]} x={box.iconRect.x} y={box.iconRect.y} width={box.iconRect.width} height={box.iconRect.height} />
|
||||
<text x={box.label.x} y={box.label.y} textAnchor="middle" style={{ font: JAVA_LABEL_FONT, fill: 'var(--canvas-label)' }}>
|
||||
{node.label}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
export interface CanvasProps {
|
||||
readonly view: TopologyView;
|
||||
}
|
||||
|
||||
export function Canvas({ view }: CanvasProps) {
|
||||
const t = useTranslate();
|
||||
const surface = useRef<SVGSVGElement>(null);
|
||||
const [size, setSize] = useState<Size>({ width: 0, height: 0 });
|
||||
const [viewport, setViewport] = useState<Viewport | null>(null);
|
||||
const textWidth = useTextWidth();
|
||||
|
||||
const boxes = useMemo(() => new Map(view.nodes.map((node) => [node.id, deviceBox(node, textWidth(node.label))])), [view.nodes, textWidth]);
|
||||
const content = useMemo(() => bounds([...boxes.values(), ...view.docs.map((item) => ({ x: item.x, y: item.y, width: item.width, height: item.height }))]), [boxes, view.docs]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const element = surface.current;
|
||||
if (element === null) return undefined;
|
||||
const observer = new ResizeObserver(([entry]) => setSize({ width: entry!.contentRect.width, height: entry!.contentRect.height }));
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// The first view shows all; after that the view is the user's.
|
||||
useEffect(() => {
|
||||
if (viewport === null && size.width > 0 && size.height > 0) setViewport(fitted(content, size));
|
||||
}, [viewport, size, content]);
|
||||
|
||||
const current = viewport ?? { scale: 1, x: 0, y: 0 };
|
||||
const local = useCallback((clientX: number, clientY: number): Point => {
|
||||
const rect = surface.current!.getBoundingClientRect();
|
||||
return { x: clientX - rect.left, y: clientY - rect.top };
|
||||
}, []);
|
||||
|
||||
// Pointers down on the surface, and what they started.
|
||||
const pointers = useRef(new Map<number, Point>());
|
||||
const gesture = useRef<{ kind: 'press'; start: Point } | { kind: 'pan'; last: Point } | { kind: 'pinch'; spread: number; middle: Point } | null>(null);
|
||||
|
||||
const onPointerDown = (event: React.PointerEvent<SVGSVGElement>) => {
|
||||
const point = local(event.clientX, event.clientY);
|
||||
pointers.current.set(event.pointerId, point);
|
||||
if (pointers.current.size === 2) {
|
||||
const [first, second] = [...pointers.current.values()] as [Point, Point];
|
||||
gesture.current = { kind: 'pinch', spread: spread(first, second), middle: middle(first, second) };
|
||||
for (const id of pointers.current.keys()) if (!surface.current!.hasPointerCapture(id)) surface.current!.setPointerCapture(id);
|
||||
} else if (pointers.current.size === 1) {
|
||||
gesture.current = { kind: 'press', start: point };
|
||||
}
|
||||
};
|
||||
|
||||
const onPointerMove = (event: React.PointerEvent<SVGSVGElement>) => {
|
||||
if (!pointers.current.has(event.pointerId)) return;
|
||||
const point = local(event.clientX, event.clientY);
|
||||
pointers.current.set(event.pointerId, point);
|
||||
const now = gesture.current;
|
||||
if (now === null) return;
|
||||
if (now.kind === 'press') {
|
||||
if (Math.hypot(point.x - now.start.x, point.y - now.start.y) < DRAG_THRESHOLD) return;
|
||||
// Captured only now that it is a drag.
|
||||
surface.current!.setPointerCapture(event.pointerId);
|
||||
gesture.current = { kind: 'pan', last: now.start };
|
||||
}
|
||||
const active = gesture.current!;
|
||||
if (active.kind === 'pan') {
|
||||
setViewport((before) => shifted(before ?? current, { x: point.x - active.last.x, y: point.y - active.last.y }));
|
||||
gesture.current = { kind: 'pan', last: point };
|
||||
} else if (active.kind === 'pinch' && pointers.current.size === 2) {
|
||||
const [first, second] = [...pointers.current.values()] as [Point, Point];
|
||||
const nextSpread = spread(first, second);
|
||||
const nextMiddle = middle(first, second);
|
||||
setViewport((before) => {
|
||||
const base = before ?? current;
|
||||
const zoomed = zoomedAt(base, base.scale * (nextSpread / Math.max(1, active.spread)), active.middle);
|
||||
return shifted(zoomed, { x: nextMiddle.x - active.middle.x, y: nextMiddle.y - active.middle.y });
|
||||
});
|
||||
gesture.current = { kind: 'pinch', spread: nextSpread, middle: nextMiddle };
|
||||
}
|
||||
};
|
||||
|
||||
const onPointerUp = (event: React.PointerEvent<SVGSVGElement>) => {
|
||||
pointers.current.delete(event.pointerId);
|
||||
if (surface.current?.hasPointerCapture(event.pointerId) === true) surface.current.releasePointerCapture(event.pointerId);
|
||||
// A pinch that loses a finger goes on as a pan with the other.
|
||||
const [remaining] = [...pointers.current.values()];
|
||||
gesture.current = remaining === undefined ? null : { kind: 'pan', last: remaining };
|
||||
};
|
||||
|
||||
// The wheel: with Ctrl (and a trackpad's pinch, which comes as Ctrl and the wheel) it zooms, else it moves.
|
||||
// Registered by hand: React's wheel listener is passive and could not keep the page from zooming.
|
||||
useEffect(() => {
|
||||
const element = surface.current;
|
||||
if (element === null) return undefined;
|
||||
const onWheel = (event: WheelEvent) => {
|
||||
event.preventDefault();
|
||||
const point = local(event.clientX, event.clientY);
|
||||
setViewport((before) => {
|
||||
const base = before ?? { scale: 1, x: 0, y: 0 };
|
||||
if (event.ctrlKey || event.metaKey) return zoomedAt(base, base.scale * Math.exp(-event.deltaY * 0.01), point);
|
||||
return shifted(base, { x: -event.deltaX, y: -event.deltaY });
|
||||
});
|
||||
};
|
||||
element.addEventListener('wheel', onWheel, { passive: false });
|
||||
return () => element.removeEventListener('wheel', onWheel);
|
||||
}, [local]);
|
||||
|
||||
const centre = { x: size.width / 2, y: size.height / 2 };
|
||||
const style: CSSProperties = { width: '100%', height: '100%', display: 'block', background: 'var(--canvas-bg)', touchAction: 'none', userSelect: 'none' };
|
||||
return (
|
||||
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
|
||||
<svg
|
||||
ref={surface}
|
||||
className="filius-canvas"
|
||||
role="application"
|
||||
aria-label={t('canvas.label')}
|
||||
style={style}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
>
|
||||
<g className="surface" transform={`translate(${current.x} ${current.y}) scale(${current.scale})`}>
|
||||
<g className="docs">
|
||||
{view.docs.map((item) => (
|
||||
<Documentation key={item.id} item={item} />
|
||||
))}
|
||||
</g>
|
||||
<g className="cables" fill="none" strokeWidth={2}>
|
||||
{view.links.map((link) => {
|
||||
const [source, target] = [boxes.get(link.sourceNode), boxes.get(link.targetNode)];
|
||||
// Drawn tolerantly: a cable whose device is gone (before the janitor) is left out.
|
||||
if (source === undefined || target === undefined) return null;
|
||||
return (
|
||||
<path
|
||||
key={link.id}
|
||||
data-link={link.id}
|
||||
d={cablePath(source.center, target.center)}
|
||||
stroke={link.wireless ? 'var(--canvas-cable-wireless)' : 'var(--canvas-cable)'}
|
||||
strokeDasharray={link.wireless ? '10' : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
<g className="devices">
|
||||
{view.nodes.map((node) => (
|
||||
<Device key={node.id} node={node} box={boxes.get(node.id)!} kindName={t(`device.${node.kind}`)} />
|
||||
))}
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
<ZoomBar
|
||||
scale={current.scale}
|
||||
onZoomIn={() => setViewport(zoomedAt(current, zoomIn(current.scale), centre))}
|
||||
onZoomOut={() => setViewport(zoomedAt(current, zoomOut(current.scale), centre))}
|
||||
onFit={() => setViewport(fitted(content, size))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// The zoom's buttons: out, the scale, in, show all. Each a target of at least
|
||||
// 44 px for a finger (plan §7.2).
|
||||
|
||||
import type { CSSProperties } from 'react';
|
||||
import { useTranslate } from '../i18n/i18n.js';
|
||||
import { MAX_ZOOM, MIN_ZOOM } from './viewport.js';
|
||||
|
||||
const bar: CSSProperties = {
|
||||
position: 'absolute',
|
||||
right: 12,
|
||||
bottom: 12,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
padding: 2,
|
||||
borderRadius: 8,
|
||||
background: 'var(--surface-raised)',
|
||||
border: '1px solid var(--border-default)',
|
||||
boxShadow: '0 1px 3px color-mix(in srgb, var(--shadow-color) 20%, transparent)',
|
||||
};
|
||||
|
||||
const button: CSSProperties = {
|
||||
minWidth: 44,
|
||||
height: 44,
|
||||
border: 'none',
|
||||
borderRadius: 6,
|
||||
background: 'transparent',
|
||||
color: 'var(--text-primary)',
|
||||
font: 'inherit',
|
||||
fontSize: 18,
|
||||
cursor: 'pointer',
|
||||
};
|
||||
|
||||
export function ZoomBar({ scale, onZoomIn, onZoomOut, onFit }: { readonly scale: number; readonly onZoomIn: () => void; readonly onZoomOut: () => void; readonly onFit: () => void }) {
|
||||
const t = useTranslate();
|
||||
return (
|
||||
<div style={bar} role="toolbar">
|
||||
<button type="button" style={button} onClick={onZoomOut} disabled={scale <= MIN_ZOOM + 0.001} aria-label={t('zoom.out')} title={t('zoom.out')}>
|
||||
−
|
||||
</button>
|
||||
<span style={{ minWidth: 56, textAlign: 'center', fontSize: 14, color: 'var(--text-secondary)' }} data-zoom={scale}>
|
||||
{t('zoom.level', { percent: Math.round(scale * 100) })}
|
||||
</span>
|
||||
<button type="button" style={button} onClick={onZoomIn} disabled={scale >= MAX_ZOOM - 0.001} aria-label={t('zoom.in')} title={t('zoom.in')}>
|
||||
+
|
||||
</button>
|
||||
<button type="button" style={{ ...button, fontSize: 16 }} onClick={onFit} aria-label={t('zoom.fit')} title={t('zoom.fit')}>
|
||||
⤢
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Java FILIUS' own hardware icons (GPL, src/main/resources/gfx/hardware),
|
||||
// so that teachers recognise their worksheets (plan §7.1).
|
||||
|
||||
import cloud from '../assets/hardware/cloud.png';
|
||||
import gateway from '../assets/hardware/gateway.png';
|
||||
import laptop from '../assets/hardware/laptop.png';
|
||||
import modem from '../assets/hardware/modem.png';
|
||||
import router from '../assets/hardware/router.png';
|
||||
import server from '../assets/hardware/server.png';
|
||||
import networkSwitch from '../assets/hardware/switch.png';
|
||||
import type { IconName } from './layout.js';
|
||||
|
||||
export const ICON_URLS: Readonly<Record<IconName, string>> = { cloud, gateway, laptop, modem, router, server, switch: networkSwitch };
|
||||
@@ -0,0 +1,111 @@
|
||||
// Where Java draws a device and a cable (filius.gui.netzwerksicht), so that a
|
||||
// project looks as on the school's PCs and worksheets fit.
|
||||
//
|
||||
// A device is a JSidebarButton: its icon above, its label below, centred. The
|
||||
// position a file stores is the button's top left corner. Its width is the
|
||||
// wider of label and icon plus 5 and twice the border of 8, its height the
|
||||
// font's height plus the icon's plus 5 (JSidebarButton.getWidth/getHeight);
|
||||
// the icon sits at the top. A cable runs between the middles of the two
|
||||
// buttons — a little below the icons' middles — as a quadratic curve whose
|
||||
// control point lies a quarter, or for a falling line three quarters, across
|
||||
// the rectangle the two ends span (JCablePanel.paintComponent, Java's
|
||||
// default icons; integer arithmetic as Java's).
|
||||
|
||||
import type { NodeKind } from '@mdg-filius/core';
|
||||
import type { Point, Rect } from './viewport.js';
|
||||
|
||||
/**
|
||||
* The label's font. Java takes the system's look and feel (Main: the system
|
||||
* look and feel), so its labels are as large as the saving machine's: in the
|
||||
* bounds of the files Java 2.5 and later saved, mostly a font 17 high whose
|
||||
* widths are Arial's at 14 px ("Vermittlungsrechner" 126 wide in Java, 125
|
||||
* here). Arial is what most of them ran on (Windows) and what the editor
|
||||
* mostly runs on (Helvetica on an iPad has its widths).
|
||||
*/
|
||||
export const JAVA_LABEL_FONT = '14px Arial, Helvetica, "Liberation Sans", "DejaVu Sans", sans-serif';
|
||||
/** Its FontMetrics.getHeight, as most of the files' bounds have it. */
|
||||
export const JAVA_LABEL_FONT_HEIGHT = 17;
|
||||
const JAVA_LABEL_ASCENT = 14;
|
||||
const BORDER = 8;
|
||||
|
||||
/** Java's default icons (IconMode.DEFAULT) and their sizes. */
|
||||
export const ICONS = {
|
||||
server: { width: 64, height: 94 },
|
||||
laptop: { width: 84, height: 80 },
|
||||
switch: { width: 70, height: 52 },
|
||||
cloud: { width: 70, height: 52 },
|
||||
router: { width: 77, height: 58 },
|
||||
gateway: { width: 77, height: 58 },
|
||||
modem: { width: 80, height: 56 },
|
||||
} as const;
|
||||
|
||||
export type IconName = keyof typeof ICONS;
|
||||
|
||||
export function iconOf(kind: NodeKind, cloud: boolean): IconName {
|
||||
switch (kind) {
|
||||
case 'pc':
|
||||
return 'server';
|
||||
case 'notebook':
|
||||
return 'laptop';
|
||||
case 'networkSwitch':
|
||||
return cloud ? 'cloud' : 'switch';
|
||||
case 'router':
|
||||
return 'router';
|
||||
case 'gateway':
|
||||
return 'gateway';
|
||||
case 'remoteLink':
|
||||
return 'modem';
|
||||
case 'unsupported':
|
||||
// As the iPad app draws a device it does not know.
|
||||
return 'cloud';
|
||||
}
|
||||
}
|
||||
|
||||
export interface DeviceBox extends Rect {
|
||||
readonly icon: IconName;
|
||||
readonly iconRect: Rect;
|
||||
/** The label's baseline, centred at x. */
|
||||
readonly label: Point;
|
||||
/** Where cables meet the device. */
|
||||
readonly center: Point;
|
||||
}
|
||||
|
||||
/** A device's button, as JSidebarButton lays it out, with the label's width as the browser measures it. */
|
||||
export function deviceBox(node: { readonly kind: NodeKind; readonly cloud: boolean; readonly x: number; readonly y: number }, labelWidth: number): DeviceBox {
|
||||
const icon = iconOf(node.kind, node.cloud);
|
||||
const size = ICONS[icon];
|
||||
const width = Math.max(Math.round(labelWidth), size.width) + 5 + 2 * BORDER;
|
||||
const height = JAVA_LABEL_FONT_HEIGHT + size.height + 5;
|
||||
return {
|
||||
x: node.x,
|
||||
y: node.y,
|
||||
width,
|
||||
height,
|
||||
icon,
|
||||
iconRect: { x: node.x + (width - size.width) / 2, y: node.y, width: size.width, height: size.height },
|
||||
label: { x: node.x + width / 2, y: node.y + size.height + JAVA_LABEL_ASCENT },
|
||||
center: { x: Math.trunc(node.x + 0.5 * width), y: Math.trunc(node.y + 0.5 * height) },
|
||||
};
|
||||
}
|
||||
|
||||
/** JCablePanel's curve between two devices' middles, as an SVG path. */
|
||||
export function cablePath(first: Point, second: Point): string {
|
||||
const [x1, y1, x2, y2] = [first.x, first.y, second.x, second.y];
|
||||
// The panel spans the two ends with 2 to spare (updateBounds); the control point is relative to it.
|
||||
const left = Math.min(x1, x2) - 2;
|
||||
const top = Math.min(y1, y2) - 2;
|
||||
let controlX = Math.trunc((x1 - left + x2 - left) / 4);
|
||||
if ((x1 > x2 && y1 > y2) || (x1 < x2 && y1 < y2)) controlX = 3 * controlX;
|
||||
const controlY = Math.trunc((y1 - top + y2 - top) / 4);
|
||||
return `M ${x1} ${y1} Q ${left + controlX} ${top + controlY} ${x2} ${y2}`;
|
||||
}
|
||||
|
||||
/** The rectangle around all of them. */
|
||||
export function bounds(rects: readonly Rect[]): Rect {
|
||||
if (rects.length === 0) return { x: 0, y: 0, width: 0, height: 0 };
|
||||
const left = Math.min(...rects.map((rect) => rect.x));
|
||||
const top = Math.min(...rects.map((rect) => rect.y));
|
||||
const right = Math.max(...rects.map((rect) => rect.x + rect.width));
|
||||
const bottom = Math.max(...rects.map((rect) => rect.y + rect.height));
|
||||
return { x: left, y: top, width: right - left, height: bottom - top };
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// The view onto the surface: how far it is zoomed and shifted — the
|
||||
// arithmetic behind it, without a browser.
|
||||
//
|
||||
// Copied from the dashboard's Kanban pinboard (frontend/src/apps/kanban/
|
||||
// zoom.js; the rights are ours, plan §7.1) and changed from a scrolled
|
||||
// container to a transform, as an SVG surface wants it:
|
||||
//
|
||||
// screen pixel = surface pixel × scale + shift
|
||||
//
|
||||
// Surface pixels are Java's: what the document stores and the .fls file has.
|
||||
// Screen pixels are what the browser reports for a pointer.
|
||||
|
||||
export const MIN_ZOOM = 0.25;
|
||||
export const MAX_ZOOM = 2;
|
||||
|
||||
/** The steps of the two buttons: a fixed ladder, so that one gets back to 100 % without searching. */
|
||||
export const ZOOM_STEPS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2] as const;
|
||||
|
||||
export interface Point {
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
}
|
||||
|
||||
export interface Size {
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
}
|
||||
|
||||
export interface Rect extends Point, Size {}
|
||||
|
||||
export interface Viewport {
|
||||
readonly scale: number;
|
||||
/** Where the surface's origin is on the screen. */
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
}
|
||||
|
||||
export const clampZoom = (zoom: number): number => Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom));
|
||||
|
||||
/** The next step up, from any value in between. */
|
||||
export function zoomIn(zoom: number): number {
|
||||
return ZOOM_STEPS.find((step) => step > zoom + 0.001) ?? MAX_ZOOM;
|
||||
}
|
||||
|
||||
/** The next step down. */
|
||||
export function zoomOut(zoom: number): number {
|
||||
const smaller = ZOOM_STEPS.filter((step) => step < zoom - 0.001);
|
||||
return smaller.at(-1) ?? MIN_ZOOM;
|
||||
}
|
||||
|
||||
export function toSurface(viewport: Viewport, point: Point): Point {
|
||||
return { x: (point.x - viewport.x) / viewport.scale, y: (point.y - viewport.y) / viewport.scale };
|
||||
}
|
||||
|
||||
export function toScreen(viewport: Viewport, point: Point): Point {
|
||||
return { x: point.x * viewport.scale + viewport.x, y: point.y * viewport.scale + viewport.y };
|
||||
}
|
||||
|
||||
/**
|
||||
* Zoomed to a scale so that a point stays put — the pointer for the wheel,
|
||||
* the middle between two fingers, the middle of the view for the buttons.
|
||||
* Without it the content jumps away from where one zooms.
|
||||
*/
|
||||
export function zoomedAt(viewport: Viewport, scale: number, point: Point): Viewport {
|
||||
const to = clampZoom(scale);
|
||||
const surface = toSurface(viewport, point);
|
||||
return { scale: to, x: point.x - surface.x * to, y: point.y - surface.y * to };
|
||||
}
|
||||
|
||||
export function shifted(viewport: Viewport, by: Point): Viewport {
|
||||
return { ...viewport, x: viewport.x + by.x, y: viewport.y + by.y };
|
||||
}
|
||||
|
||||
/**
|
||||
* The view in which the content fits, in the middle, with a margin. Never
|
||||
* above 100 %: fitting means zooming out until all is in view; three devices
|
||||
* on a large screen at 200 % is not what anyone means by it.
|
||||
*/
|
||||
export function fitted(content: Rect, view: Size, margin = 24): Viewport {
|
||||
if (!(content.width > 0) || !(content.height > 0) || !(view.width > 2 * margin) || !(view.height > 2 * margin)) {
|
||||
return { scale: 1, x: margin - (content.width > 0 ? content.x : 0), y: margin - (content.height > 0 ? content.y : 0) };
|
||||
}
|
||||
const scale = clampZoom(Math.min(1, (view.width - 2 * margin) / content.width, (view.height - 2 * margin) / content.height));
|
||||
return {
|
||||
scale,
|
||||
x: (view.width - content.width * scale) / 2 - content.x * scale,
|
||||
y: (view.height - content.height * scale) / 2 - content.y * scale,
|
||||
};
|
||||
}
|
||||
|
||||
/** The distance between two touches: the measure of the pinch. */
|
||||
export function spread(first: Point, second: Point): number {
|
||||
return Math.hypot(second.x - first.x, second.y - first.y);
|
||||
}
|
||||
|
||||
/** The middle between two touches. */
|
||||
export function middle(first: Point, second: Point): Point {
|
||||
return { x: (first.x + second.x) / 2, y: (first.y + second.y) / 2 };
|
||||
}
|
||||
Reference in New Issue
Block a user