mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-05 06:08:59 +00:00
467 lines
15 KiB
TypeScript
467 lines
15 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;
|
|
roomId?: string;
|
|
}
|
|
|
|
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;
|
|
roomId?: string;
|
|
}
|
|
|
|
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 plan-surface shortcut.
|
|
* The first independently interactive owner wins; otherwise a room surface or
|
|
* the stage background may contribute one clean tap.
|
|
*/
|
|
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.owner.kind !== 'room')
|
|
|| 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,
|
|
...(input.owner.kind === 'room' ? { roomId: input.owner.roomId } : {}),
|
|
};
|
|
}
|
|
|
|
/** Pure release arbitration. Invalid input always disarms the previous tap. */
|
|
export function completeDoubleFitPointer(
|
|
sequence: DoubleFitTapSequence | null,
|
|
candidate: DoubleFitPointerCandidate | null,
|
|
input: DoubleFitPointerUpInput,
|
|
): DoubleFitResult {
|
|
const sameOwner = !!candidate && (candidate.roomId
|
|
? input.owner.kind === 'room' && input.owner.roomId === candidate.roomId
|
|
: input.owner.kind === 'background');
|
|
if (!candidate || input.mode !== 'view' || !sameOwner
|
|
|| 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,
|
|
...(candidate.roomId ? { roomId: candidate.roomId } : {}),
|
|
};
|
|
}
|
|
|
|
/** 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;
|
|
}
|
|
|
|
clearNonPlan(event: DoubleFitPointerEventLike): boolean {
|
|
let owner: PlanGestureOwner = { kind: 'outside' };
|
|
try { owner = planGestureOwnerFromPath(event.composedPath()); } catch { /* fail closed */ }
|
|
if (owner.kind !== 'background' && owner.kind !== 'room') {
|
|
this.clear();
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
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(),
|
|
): DoubleFitResult {
|
|
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;
|
|
}
|
|
}
|
|
|
|
export interface PlanTapTimerPort {
|
|
set(callback: () => void, delayMs: number): unknown;
|
|
clear(handle: unknown): void;
|
|
}
|
|
|
|
export interface PlanTapGestureActions {
|
|
current(): { spaceId: string; enabled: boolean };
|
|
fitAll(): void;
|
|
fitRoom(roomId: string): void;
|
|
}
|
|
|
|
const browserPlanTapTimers: PlanTapTimerPort = {
|
|
set: (callback, delayMs) => globalThis.setTimeout(callback, delayMs),
|
|
clear: (handle) => globalThis.clearTimeout(handle as number),
|
|
};
|
|
|
|
/** One owner for the double-fit recognizer and its sole delayed room command. */
|
|
export class PlanTapGestureController {
|
|
private readonly recognizer = new DoubleFitGestureRecognizer();
|
|
private pending: { spaceId: string; roomId: string } | null = null;
|
|
private timer: unknown;
|
|
|
|
constructor(
|
|
private readonly actions: PlanTapGestureActions,
|
|
private readonly timers: PlanTapTimerPort = browserPlanTapTimers,
|
|
) {}
|
|
|
|
cancelPending(): void {
|
|
if (this.timer !== undefined) this.timers.clear(this.timer);
|
|
this.timer = undefined;
|
|
this.pending = null;
|
|
}
|
|
|
|
clear(): void {
|
|
this.cancelPending();
|
|
this.recognizer.clear();
|
|
}
|
|
|
|
clearNonPlan(event: DoubleFitPointerEventLike): boolean {
|
|
if (!this.recognizer.clearNonPlan(event)) return false;
|
|
this.cancelPending();
|
|
return true;
|
|
}
|
|
|
|
pointerDown(event: DoubleFitPointerEventLike, spaceId: string, enabled: boolean): void {
|
|
this.cancelPending();
|
|
this.recognizer.pointerDown(event, spaceId, enabled);
|
|
}
|
|
|
|
pointerUp(
|
|
event: DoubleFitPointerEventLike,
|
|
roomCandidate: RoomFitGestureCandidate | null,
|
|
spaceId: string,
|
|
enabled: boolean,
|
|
blocked: boolean,
|
|
now = Date.now(),
|
|
): string | null {
|
|
const acceptedRoom = acceptedRoomFitGesture(
|
|
roomCandidate, event.pointerId, spaceId,
|
|
roomCandidate && enabled ? roomFitOwnerFromPath(event.composedPath()) : null,
|
|
blocked,
|
|
);
|
|
const result = this.recognizer.pointerUp(event, spaceId, enabled, blocked, now);
|
|
if (result.trigger) this.actions.fitAll();
|
|
else if (result.roomId && acceptedRoom === result.roomId) {
|
|
this.schedule(spaceId, result.roomId);
|
|
} else if (acceptedRoom) this.actions.fitRoom(acceptedRoom);
|
|
return acceptedRoom;
|
|
}
|
|
|
|
private schedule(spaceId: string, roomId: string): void {
|
|
this.cancelPending();
|
|
const pending = { spaceId, roomId };
|
|
this.pending = pending;
|
|
this.timer = this.timers.set(() => {
|
|
this.timer = undefined;
|
|
if (this.pending !== pending) return;
|
|
this.pending = null;
|
|
const current = this.actions.current();
|
|
if (current.spaceId !== pending.spaceId || !current.enabled) {
|
|
this.recognizer.clear();
|
|
return;
|
|
}
|
|
this.actions.fitRoom(pending.roomId);
|
|
}, DOUBLE_FIT_WINDOW_MS);
|
|
}
|
|
}
|
|
|
|
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 };
|
|
}
|