mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 12:18:51 +00:00
367 lines
12 KiB
TypeScript
367 lines
12 KiB
TypeScript
import { projectPlanPoint, type PlanPoint } from './iso-projection';
|
|
import type { CameraState, CameraViewBox } from './viewport-transition';
|
|
|
|
export interface RoomFitBounds extends CameraViewBox {}
|
|
|
|
export interface RoomFitGeometry {
|
|
floor: readonly (readonly number[])[];
|
|
wall?: readonly (readonly number[])[];
|
|
projection: 'flat' | 'iso';
|
|
wallHeight?: number;
|
|
floorDepth?: number;
|
|
padding?: number;
|
|
}
|
|
|
|
export interface RoomFitTargetInput {
|
|
bounds: RoomFitBounds;
|
|
baseFit: CameraViewBox;
|
|
stageWidth: number;
|
|
stageHeight: number;
|
|
minZoom: number;
|
|
maxZoom: number;
|
|
safeFraction?: number;
|
|
}
|
|
|
|
export interface RoomFitGestureCandidate {
|
|
pointerId: number;
|
|
spaceId: string;
|
|
roomId: string;
|
|
}
|
|
|
|
export type DoubleFitPointerModality = 'mouse' | 'touch' | 'pen';
|
|
|
|
export interface DoubleFitPointerCandidate {
|
|
pointerId: number;
|
|
spaceId: string;
|
|
modality: DoubleFitPointerModality;
|
|
x: number;
|
|
y: number;
|
|
}
|
|
|
|
export interface DoubleFitTapSequence {
|
|
at: number;
|
|
spaceId: string;
|
|
modality: DoubleFitPointerModality;
|
|
}
|
|
|
|
export type PlanGestureOwner =
|
|
| { kind: 'background' }
|
|
| { kind: 'room'; roomId: string }
|
|
| { kind: 'interactive' }
|
|
| { kind: 'outside' };
|
|
|
|
export interface DoubleFitPointerDownInput {
|
|
pointerId: number;
|
|
pointerType: string;
|
|
isPrimary: boolean;
|
|
button: number;
|
|
spaceId: string;
|
|
mode: string;
|
|
owner: PlanGestureOwner;
|
|
blocked: boolean;
|
|
x: number;
|
|
y: number;
|
|
}
|
|
|
|
export interface DoubleFitPointerUpInput {
|
|
pointerId: number;
|
|
spaceId: string;
|
|
mode: string;
|
|
owner: PlanGestureOwner;
|
|
blocked: boolean;
|
|
x: number;
|
|
y: number;
|
|
now: number;
|
|
}
|
|
|
|
export interface DoubleFitResult {
|
|
sequence: DoubleFitTapSequence | null;
|
|
trigger: boolean;
|
|
}
|
|
|
|
interface DoubleFitPointerEventLike {
|
|
pointerId: number;
|
|
pointerType: string;
|
|
isPrimary: boolean;
|
|
button: number;
|
|
clientX: number;
|
|
clientY: number;
|
|
composedPath: () => readonly unknown[];
|
|
}
|
|
|
|
interface RoomFitPathNode {
|
|
matches?: (selector: string) => boolean;
|
|
getAttribute?: (name: string) => string | null;
|
|
}
|
|
|
|
const ROOM_FIT_INTERACTIVE_OWNER = [
|
|
'.dev', '.vacpuck', '.oplock', '.op-hit', '.opening', '.rlgo',
|
|
'a', 'button', 'input', 'select', 'textarea',
|
|
'[contenteditable]:not([contenteditable="false"])',
|
|
'[data-room-fit-block]', '[role="link"]', '[role="button"]',
|
|
].join(',');
|
|
|
|
export const DOUBLE_FIT_WINDOW_MS = 350;
|
|
/** Existing kiosk clean-tap / stage pan-lock boundary. */
|
|
export const STAGE_TAP_DISTANCE_PX = 8;
|
|
|
|
const pathMatches = (node: RoomFitPathNode, selector: string): boolean => {
|
|
try {
|
|
return typeof node?.matches === 'function' && node.matches(selector);
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* One browser-path authority for room fit and the free-background shortcut.
|
|
* The first independently interactive owner wins; only a path which reaches
|
|
* this card's stage without one is free background.
|
|
*/
|
|
export function planGestureOwnerFromPath(path: readonly unknown[]): PlanGestureOwner {
|
|
for (const raw of path) {
|
|
const node = raw as RoomFitPathNode;
|
|
if (pathMatches(node, '.roomlabel[data-id]')) {
|
|
const roomId = node.getAttribute?.('data-id');
|
|
return roomId ? { kind: 'room', roomId } : { kind: 'interactive' };
|
|
}
|
|
if (pathMatches(node, ROOM_FIT_INTERACTIVE_OWNER)) return { kind: 'interactive' };
|
|
if (pathMatches(node, '[data-hp="room"][data-id]')) {
|
|
const roomId = node.getAttribute?.('data-id');
|
|
return roomId ? { kind: 'room', roomId } : { kind: 'interactive' };
|
|
}
|
|
if (pathMatches(node, '.stage')) return { kind: 'background' };
|
|
}
|
|
return { kind: 'outside' };
|
|
}
|
|
|
|
/**
|
|
* Resolve the browser-painted room owner. The first independently interactive
|
|
* node in the composed path wins; the room label itself is the one deliberate
|
|
* role=button exception because it maps back to the same room command.
|
|
*/
|
|
export function roomFitOwnerFromPath(path: readonly unknown[]): string | null {
|
|
const owner = planGestureOwnerFromPath(path);
|
|
return owner.kind === 'room' ? owner.roomId : null;
|
|
}
|
|
|
|
const pointerModality = (value: string): DoubleFitPointerModality | null =>
|
|
value === 'mouse' || value === 'touch' || value === 'pen' ? value : null;
|
|
|
|
export function beginDoubleFitPointer(
|
|
input: DoubleFitPointerDownInput,
|
|
): DoubleFitPointerCandidate | null {
|
|
const modality = pointerModality(input.pointerType);
|
|
if (!modality || input.mode !== 'view' || input.owner.kind !== 'background'
|
|
|| input.blocked || !input.isPrimary || input.button !== 0
|
|
|| !Number.isFinite(input.x) || !Number.isFinite(input.y)) return null;
|
|
return {
|
|
pointerId: input.pointerId,
|
|
spaceId: input.spaceId,
|
|
modality,
|
|
x: input.x,
|
|
y: input.y,
|
|
};
|
|
}
|
|
|
|
/** Pure release arbitration. Invalid input always disarms the previous tap. */
|
|
export function completeDoubleFitPointer(
|
|
sequence: DoubleFitTapSequence | null,
|
|
candidate: DoubleFitPointerCandidate | null,
|
|
input: DoubleFitPointerUpInput,
|
|
): DoubleFitResult {
|
|
if (!candidate || input.mode !== 'view' || input.owner.kind !== 'background'
|
|
|| input.blocked || candidate.pointerId !== input.pointerId
|
|
|| candidate.spaceId !== input.spaceId
|
|
|| !Number.isFinite(input.x) || !Number.isFinite(input.y)
|
|
|| !Number.isFinite(input.now)
|
|
|| Math.abs(input.x - candidate.x) + Math.abs(input.y - candidate.y)
|
|
>= STAGE_TAP_DISTANCE_PX) {
|
|
return { sequence: null, trigger: false };
|
|
}
|
|
|
|
const elapsed = sequence ? input.now - sequence.at : Number.POSITIVE_INFINITY;
|
|
if (sequence && sequence.spaceId === input.spaceId
|
|
&& sequence.modality === candidate.modality
|
|
&& elapsed >= 0 && elapsed <= DOUBLE_FIT_WINDOW_MS) {
|
|
return { sequence: null, trigger: true };
|
|
}
|
|
return {
|
|
sequence: { at: input.now, spaceId: input.spaceId, modality: candidate.modality },
|
|
trigger: false,
|
|
};
|
|
}
|
|
|
|
/** Per-card owner of the two transient halves; no timers, renders or writes. */
|
|
export class DoubleFitGestureRecognizer {
|
|
private pointer: DoubleFitPointerCandidate | null = null;
|
|
private sequence: DoubleFitTapSequence | null = null;
|
|
|
|
clear(): void {
|
|
this.pointer = null;
|
|
this.sequence = null;
|
|
}
|
|
|
|
clearOutside(event: DoubleFitPointerEventLike): void {
|
|
let owner: PlanGestureOwner = { kind: 'outside' };
|
|
try { owner = planGestureOwnerFromPath(event.composedPath()); } catch { /* fail closed */ }
|
|
if (owner.kind === 'outside') this.clear();
|
|
}
|
|
|
|
pointerDown(event: DoubleFitPointerEventLike, spaceId: string, enabled: boolean): void {
|
|
let owner: PlanGestureOwner = { kind: 'outside' };
|
|
try { owner = planGestureOwnerFromPath(event.composedPath()); } catch { /* fail closed */ }
|
|
this.pointer = beginDoubleFitPointer({
|
|
pointerId: event.pointerId, pointerType: event.pointerType,
|
|
isPrimary: event.isPrimary, button: event.button, spaceId,
|
|
mode: enabled ? 'view' : 'blocked', owner, blocked: !enabled,
|
|
x: event.clientX, y: event.clientY,
|
|
});
|
|
if (!this.pointer) this.sequence = null;
|
|
}
|
|
|
|
pointerUp(
|
|
event: DoubleFitPointerEventLike,
|
|
spaceId: string,
|
|
enabled: boolean,
|
|
blocked: boolean,
|
|
now = Date.now(),
|
|
): boolean {
|
|
let owner: PlanGestureOwner = { kind: 'outside' };
|
|
try { owner = planGestureOwnerFromPath(event.composedPath()); } catch { /* fail closed */ }
|
|
const result = completeDoubleFitPointer(this.sequence, this.pointer, {
|
|
pointerId: event.pointerId, spaceId, mode: enabled ? 'view' : 'blocked', owner,
|
|
blocked: blocked || !enabled, x: event.clientX, y: event.clientY, now,
|
|
});
|
|
this.pointer = null;
|
|
this.sequence = result.sequence;
|
|
return result.trigger;
|
|
}
|
|
}
|
|
|
|
export function acceptedRoomFitGesture(
|
|
candidate: RoomFitGestureCandidate | null,
|
|
pointerId: number,
|
|
spaceId: string,
|
|
upRoomId: string | null,
|
|
blocked: boolean,
|
|
): string | null {
|
|
if (!candidate || blocked || candidate.pointerId !== pointerId
|
|
|| candidate.spaceId !== spaceId || candidate.roomId !== upRoomId) return null;
|
|
return candidate.roomId;
|
|
}
|
|
|
|
const finitePoint = (point: readonly number[]): point is readonly [number, number] =>
|
|
point.length >= 2 && Number.isFinite(point[0]) && Number.isFinite(point[1]);
|
|
|
|
export function validRoomFitBounds(value: RoomFitBounds | null | undefined): value is RoomFitBounds {
|
|
return !!value && Number.isFinite(value.x) && Number.isFinite(value.y)
|
|
&& Number.isFinite(value.w) && value.w > 0
|
|
&& Number.isFinite(value.h) && value.h > 0;
|
|
}
|
|
|
|
/** Finite AABB of exact render vertices. Invalid vertices fail the whole input. */
|
|
export function roomFitPointBounds(
|
|
points: readonly (readonly number[])[], padding = 0,
|
|
): RoomFitBounds | null {
|
|
if (!points.length || !Number.isFinite(padding) || padding < 0
|
|
|| !points.every(finitePoint)) return null;
|
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
for (const point of points) {
|
|
minX = Math.min(minX, point[0]);
|
|
minY = Math.min(minY, point[1]);
|
|
maxX = Math.max(maxX, point[0]);
|
|
maxY = Math.max(maxY, point[1]);
|
|
}
|
|
const width = maxX - minX;
|
|
const height = maxY - minY;
|
|
if (!(width > 0) || !(height > 0)) return null;
|
|
return {
|
|
x: minX - padding,
|
|
y: minY - padding,
|
|
w: width + padding * 2,
|
|
h: height + padding * 2,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Bounds of the same floor/wall vertices the renderer consumes. Isometric
|
|
* bounds are built after projecting every exact vertex, never by projecting a
|
|
* plan-space rectangle around them.
|
|
*/
|
|
export function roomFitGeometryBounds(input: RoomFitGeometry): RoomFitBounds | null {
|
|
const floor = input.floor;
|
|
const wall = input.wall || [];
|
|
const padding = input.padding ?? 0;
|
|
if (!floor.length || !floor.every(finitePoint) || !wall.every(finitePoint)
|
|
|| !Number.isFinite(padding) || padding < 0) return null;
|
|
if (input.projection === 'flat') {
|
|
return roomFitPointBounds([...floor, ...wall], padding);
|
|
}
|
|
|
|
const wallHeight = input.wallHeight ?? 0;
|
|
const floorDepth = input.floorDepth ?? 0;
|
|
if (!Number.isFinite(wallHeight) || wallHeight < 0
|
|
|| !Number.isFinite(floorDepth) || floorDepth < 0) return null;
|
|
try {
|
|
const points: PlanPoint[] = [];
|
|
for (const point of floor) {
|
|
const plan: PlanPoint = [point[0], point[1]];
|
|
points.push(projectPlanPoint(plan, 0));
|
|
if (floorDepth > 0) points.push(projectPlanPoint(plan, -floorDepth));
|
|
}
|
|
for (const point of wall) {
|
|
const plan: PlanPoint = [point[0], point[1]];
|
|
points.push(projectPlanPoint(plan, 0));
|
|
if (wallHeight > 0) points.push(projectPlanPoint(plan, wallHeight));
|
|
}
|
|
return roomFitPointBounds(points, padding);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Exact 10%-safe camera target expressed in the card's existing zoom model. */
|
|
export function roomFitCameraTarget(input: RoomFitTargetInput): CameraState | null {
|
|
const safe = input.safeFraction ?? 0.8;
|
|
const { bounds, baseFit, stageWidth, stageHeight, minZoom, maxZoom } = input;
|
|
if (!validRoomFitBounds(bounds) || !validRoomFitBounds(baseFit)
|
|
|| !Number.isFinite(stageWidth) || stageWidth <= 0
|
|
|| !Number.isFinite(stageHeight) || stageHeight <= 0
|
|
|| !Number.isFinite(minZoom) || minZoom <= 0
|
|
|| !Number.isFinite(maxZoom) || maxZoom < minZoom
|
|
|| !Number.isFinite(safe) || safe <= 0 || safe > 1) return null;
|
|
const aspect = stageWidth / stageHeight;
|
|
const requiredWidth = Math.max(bounds.w / safe, (bounds.h / safe) * aspect);
|
|
if (!Number.isFinite(requiredWidth) || requiredWidth <= 0) return null;
|
|
const zoom = Math.min(maxZoom, Math.max(minZoom, baseFit.w / requiredWidth));
|
|
const width = baseFit.w / zoom;
|
|
const height = width / aspect;
|
|
const centerX = bounds.x + bounds.w / 2;
|
|
const centerY = bounds.y + bounds.h / 2;
|
|
return {
|
|
zoom,
|
|
viewBox: {
|
|
x: centerX - width / 2,
|
|
y: centerY - height / 2,
|
|
w: width,
|
|
h: height,
|
|
},
|
|
};
|
|
}
|
|
|
|
/** A clamp frame covering both the painted and target cameras without moving either. */
|
|
export function roomFitClampFrame(
|
|
baseFit: CameraViewBox,
|
|
current: CameraViewBox,
|
|
target: CameraViewBox,
|
|
bounds: RoomFitBounds,
|
|
): CameraViewBox {
|
|
const rects = [baseFit, current, target, bounds];
|
|
const x = Math.min(...rects.map((rect) => rect.x));
|
|
const y = Math.min(...rects.map((rect) => rect.y));
|
|
const right = Math.max(...rects.map((rect) => rect.x + rect.w));
|
|
const bottom = Math.max(...rects.map((rect) => rect.y + rect.h));
|
|
return { x, y, w: right - x, h: bottom - y };
|
|
}
|