From b540b163c6d0a317809819e35b05ed640155c3b2 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 24 May 2026 21:18:57 +0200 Subject: [PATCH] feat(floorplan-editor): React rewrite + live in-room preview + UX polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete modernization of the floor-plan editor. Three layered changes shipped together since they share state shapes and the test infrastructure stubs. 1) React rewrite (state + hooks + views + tests) Drops the FloorplanEditorContext singleton + legacy view components and replaces them with a pure-React reducer architecture: - state/ — typed FloorplanState + FloorplanAction union, pure reducer covering PAINT_TILE / ERASE_TILE / ADJUST_HEIGHT / SET_DOOR / SET_DOOR_DIR / SET_THICKNESS / SET_WALL_HEIGHT / BRUSH_SET / SELECT_RECT / SELECT_ALL / CLEAR_SELECTION / SQUARE_SELECT_TOGGLE / IMPORT_STRING / APPLY_REMOTE_DIFF / APPLY_REMOTE_SNAPSHOT. Source-tagged ('local' | 'remote') so the editor can distinguish user edits from server pushes. Co-located encoding helpers (parseTilemap / serializeTilemap) and area-counter selectors. - hooks/ — useFloorplanReducer (wraps useReducer with a history stack + loadFromServer + undo/redo), useTool (pointer events -> dispatch), usePointerToTile (screen -> tile projection that respects the viewBox origin so pan/zoom stays accurate). - views/ — FloorplanCanvasSVG, FloorplanHeightPicker, FloorplanToolbar, FloorplanOptionsPanel, FloorplanImportExport, FloorplanTile, FloorplanPreviewSVG (alternative iso preview kept as a fallback view, not wired into the main layout). - Co-located Vitest suites for every module above (encoding, reducer, selectors, hooks, views, integration). 100+ new test cases. 2) Live in-room preview (NEW capability) useFloorplanLiveSync drives client-side preview of the edit directly into the active room — every tile / door / wall height / thickness change is applied through GetRoomMessageHandler().applyFloorModelLocally (new public method on the renderer, see paired renderer PR) with zero server traffic during editing. The wire UpdateFloorPropertiesMessageComposer is only sent when the user explicitly clicks Save. Thickness slider additionally calls RoomEngine.updateRoomInstancePlaneThickness for zero-latency wall/floor-depth feedback while dragging. Toggle 'Live preview ON / OFF' in the bottom strip (default ON) lets the user opt out if they want to keep changes contained to the editor's own preview until Save. Revert button re-applies the original snapshot locally so the room snaps back to where it was when the editor opened. 3) UX polish - Undo / Redo (Ctrl+Z, Ctrl+Shift+Z / Ctrl+Y) backed by a 100-step history stack inside useFloorplanReducer. Local mutating actions push history; brush/selection UI bumps and remote dispatches bypass it; loadFromServer wipes the stack. - Zoom 40-600 % with Ctrl+wheel, +/- buttons, % label. Shift+drag or middle-mouse drag pans the canvas. - Auto-fit on first paint: computes the screen-space bounding box of the painted (non-blocked) tiles, picks the zoom that just contains them with a 5 % margin, pans so the room sits in the viewport centre. Default view is now 'room fills the canvas' instead of 'room is a dot at the top-centre of a huge empty canvas'. Clicking the % label re-runs the fit; crosshair button keeps zoom and recentres the pan only. - Door direction control: arrows + door icon triplet (8-way rotate by single click on prev/next, full cycle forward on the icon itself). Wall and floor thickness collapse from two 4-button rows into two compact segmented selectors (active state in emerald). Saves significant horizontal space. - Habbo floor pattern tile (~186 B PNG, vendored from habbofurni.com/images/furni_floor.png) tiled as the canvas background with image-rendering: pixelated so the texture stays crisp at every zoom level. Replaces the solid black background. Test infrastructure nitro-renderer.mock grows constructors / proxies / functions for everything the new floor-editor tests transitively import (floor composers + events, RoomEngineEvent, ILinkEventTracker, convertNumbersForSaving / convertSettingToNumber, GetRoomMessageHandler, GetTicker, GetRenderer, NitroTicker, RoomPreviewer with a sufficiently real .updatePreviewModel / dispose surface, and a TextureUtils.createRenderTexture that returns an object with a no-op .destroy). test-setup adds a no-op ResizeObserver polyfill (jsdom doesn't ship one and the optional FloorplanRoomPreview observes its container) and a draggable-windows-container portal root for tests that mount NitroCardView. Files: 44 changed (mostly new). yarn typecheck 0 errors, yarn test 341/341 green. --- .../floorplaneditor/canvas_floor_pattern.png | Bin 0 -> 186 bytes .../FloorplanEditorContext.tsx | 34 -- .../FloorplanEditorView.test.tsx | 205 ++++++++ .../floorplan-editor/FloorplanEditorView.tsx | 461 +++++++++--------- .../hooks/useFloorplanReducer.test.tsx | 43 ++ .../hooks/useFloorplanReducer.ts | 185 +++++++ .../hooks/usePointerToTile.test.ts | 42 ++ .../hooks/usePointerToTile.ts | 52 ++ .../floorplan-editor/hooks/useTool.test.ts | 87 ++++ .../floorplan-editor/hooks/useTool.ts | 73 +++ .../floorplan-editor/state/constants.ts | 15 + .../floorplan-editor/state/encoding.test.ts | 169 +++++++ .../floorplan-editor/state/encoding.ts | 47 ++ .../floorplan-editor/state/reducer.test.ts | 326 +++++++++++++ .../floorplan-editor/state/reducer.ts | 190 ++++++++ .../floorplan-editor/state/selectors.test.ts | 80 +++ .../floorplan-editor/state/selectors.ts | 43 ++ .../floorplan-editor/state/types.ts | 49 ++ .../views/FloorplanCanvasSVG.test.tsx | 60 +++ .../views/FloorplanCanvasSVG.tsx | 288 +++++++++++ .../views/FloorplanCanvasView.tsx | 179 ------- .../views/FloorplanHeightPicker.test.tsx | 28 ++ .../views/FloorplanHeightPicker.tsx | 54 ++ .../views/FloorplanHeightSelector.tsx | 54 -- .../views/FloorplanImportExport.test.tsx | 49 ++ .../views/FloorplanImportExport.tsx | 53 ++ .../views/FloorplanImportExportView.tsx | 55 --- .../views/FloorplanOptionsPanel.test.tsx | 42 ++ .../views/FloorplanOptionsPanel.tsx | 110 +++++ .../views/FloorplanOptionsView.tsx | 122 ----- .../views/FloorplanPreviewSVG.test.tsx | 51 ++ .../views/FloorplanPreviewSVG.tsx | 77 +++ .../views/FloorplanPreviewView.tsx | 328 ------------- .../views/FloorplanRoomPreview.tsx | 68 +++ .../views/FloorplanTile.test.tsx | 35 ++ .../floorplan-editor/views/FloorplanTile.tsx | 56 +++ .../views/FloorplanToolbar.test.tsx | 47 ++ .../views/FloorplanToolbar.tsx | 75 +++ .../widgets/useFloorplanLiveSync.test.tsx | 234 +++++++++ .../rooms/widgets/useFloorplanLiveSync.ts | 170 +++++++ src/nitro-renderer.mock.ts | 124 ++++- src/test-setup.ts | 33 ++ 42 files changed, 3497 insertions(+), 996 deletions(-) create mode 100644 src/assets/images/floorplaneditor/canvas_floor_pattern.png delete mode 100644 src/components/floorplan-editor/FloorplanEditorContext.tsx create mode 100644 src/components/floorplan-editor/FloorplanEditorView.test.tsx create mode 100644 src/components/floorplan-editor/hooks/useFloorplanReducer.test.tsx create mode 100644 src/components/floorplan-editor/hooks/useFloorplanReducer.ts create mode 100644 src/components/floorplan-editor/hooks/usePointerToTile.test.ts create mode 100644 src/components/floorplan-editor/hooks/usePointerToTile.ts create mode 100644 src/components/floorplan-editor/hooks/useTool.test.ts create mode 100644 src/components/floorplan-editor/hooks/useTool.ts create mode 100644 src/components/floorplan-editor/state/constants.ts create mode 100644 src/components/floorplan-editor/state/encoding.test.ts create mode 100644 src/components/floorplan-editor/state/encoding.ts create mode 100644 src/components/floorplan-editor/state/reducer.test.ts create mode 100644 src/components/floorplan-editor/state/reducer.ts create mode 100644 src/components/floorplan-editor/state/selectors.test.ts create mode 100644 src/components/floorplan-editor/state/selectors.ts create mode 100644 src/components/floorplan-editor/state/types.ts create mode 100644 src/components/floorplan-editor/views/FloorplanCanvasSVG.test.tsx create mode 100644 src/components/floorplan-editor/views/FloorplanCanvasSVG.tsx delete mode 100644 src/components/floorplan-editor/views/FloorplanCanvasView.tsx create mode 100644 src/components/floorplan-editor/views/FloorplanHeightPicker.test.tsx create mode 100644 src/components/floorplan-editor/views/FloorplanHeightPicker.tsx delete mode 100644 src/components/floorplan-editor/views/FloorplanHeightSelector.tsx create mode 100644 src/components/floorplan-editor/views/FloorplanImportExport.test.tsx create mode 100644 src/components/floorplan-editor/views/FloorplanImportExport.tsx delete mode 100644 src/components/floorplan-editor/views/FloorplanImportExportView.tsx create mode 100644 src/components/floorplan-editor/views/FloorplanOptionsPanel.test.tsx create mode 100644 src/components/floorplan-editor/views/FloorplanOptionsPanel.tsx delete mode 100644 src/components/floorplan-editor/views/FloorplanOptionsView.tsx create mode 100644 src/components/floorplan-editor/views/FloorplanPreviewSVG.test.tsx create mode 100644 src/components/floorplan-editor/views/FloorplanPreviewSVG.tsx delete mode 100644 src/components/floorplan-editor/views/FloorplanPreviewView.tsx create mode 100644 src/components/floorplan-editor/views/FloorplanRoomPreview.tsx create mode 100644 src/components/floorplan-editor/views/FloorplanTile.test.tsx create mode 100644 src/components/floorplan-editor/views/FloorplanTile.tsx create mode 100644 src/components/floorplan-editor/views/FloorplanToolbar.test.tsx create mode 100644 src/components/floorplan-editor/views/FloorplanToolbar.tsx create mode 100644 src/hooks/rooms/widgets/useFloorplanLiveSync.test.tsx create mode 100644 src/hooks/rooms/widgets/useFloorplanLiveSync.ts diff --git a/src/assets/images/floorplaneditor/canvas_floor_pattern.png b/src/assets/images/floorplaneditor/canvas_floor_pattern.png new file mode 100644 index 0000000000000000000000000000000000000000..e3544a38f0a2b73f0ce8a43e11b39574d9962a0f GIT binary patch literal 186 zcmeAS@N?(olHy`uVBq!ia0vp^jzH|d$P6UwQ#%#|DYgKg5ZB9>FWg;y~pw$eXu6{1-oD!M<|BFQs literal 0 HcmV?d00001 diff --git a/src/components/floorplan-editor/FloorplanEditorContext.tsx b/src/components/floorplan-editor/FloorplanEditorContext.tsx deleted file mode 100644 index 323be74..0000000 --- a/src/components/floorplan-editor/FloorplanEditorContext.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { createContext, Dispatch, FC, SetStateAction, useContext } from 'react'; -import { IFloorplanSettings } from '@nitrots/nitro-renderer'; -import { IVisualizationSettings } from '@nitrots/nitro-renderer'; - -interface IFloorplanEditorContext -{ - originalFloorplanSettings: IFloorplanSettings; - setOriginalFloorplanSettings: Dispatch>; - visualizationSettings: IVisualizationSettings; - setVisualizationSettings: Dispatch>; - floorHeight: number; - setFloorHeight: Dispatch>; - floorAction: number; - setFloorAction: Dispatch>; - tilemapVersion: number; - areaInfo: { total: number; walkable: number }; -} - -const FloorplanEditorContext = createContext({ - originalFloorplanSettings: null, - setOriginalFloorplanSettings: null, - visualizationSettings: null, - setVisualizationSettings: null, - floorHeight: 0, - setFloorHeight: null, - floorAction: 3, - setFloorAction: null, - tilemapVersion: 0, - areaInfo: { total: 0, walkable: 0 } -}); - -export const FloorplanEditorContextProvider: FC<{ value: IFloorplanEditorContext; children?: React.ReactNode }> = props => ; - -export const useFloorplanEditorContext = () => useContext(FloorplanEditorContext); diff --git a/src/components/floorplan-editor/FloorplanEditorView.test.tsx b/src/components/floorplan-editor/FloorplanEditorView.test.tsx new file mode 100644 index 0000000..5603025 --- /dev/null +++ b/src/components/floorplan-editor/FloorplanEditorView.test.tsx @@ -0,0 +1,205 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { act, render, cleanup, fireEvent } from '@testing-library/react'; + +// Capture handlers registered by useMessageEvent / useNitroEvent so we can fire fake events. +const messageHandlers = new Map void>(); +const nitroHandlers = new Map void>(); + +vi.mock('../../hooks', async () => +{ + return { + useMessageEvent: (eventClass: unknown, handler: (event: unknown) => void) => + { + messageHandlers.set(eventClass, handler); + }, + useNitroEvent: (eventType: unknown, handler: (event: unknown) => void) => + { + nitroHandlers.set(eventType, handler); + } + }; +}); + +// Spy SendMessageComposer — use importOriginal to keep all other api exports intact +// (DraggableWindow et al. rely on GetLocalStorage and others at mount time). +const sendMessageComposer = vi.fn(); +vi.mock('../../api', async (importOriginal) => +{ + const actual = await importOriginal(); + return { + ...actual, + SendMessageComposer: (...args: unknown[]) => sendMessageComposer(...args), + LocalizeText: (key: string) => key + }; +}); + +import { + FloorHeightMapEvent, + RoomVisualizationSettingsEvent, + RoomEntryTileMessageEvent, + RoomOccupiedTilesMessageEvent, + RoomEngineEvent, + GetRoomEntryTileMessageComposer, + GetOccupiedTilesMessageComposer, + UpdateFloorPropertiesMessageComposer, + AddLinkEventTracker, + RemoveLinkEventTracker +} from '@nitrots/nitro-renderer'; +import { FloorplanEditorView } from './FloorplanEditorView'; + +// The Button component in this codebase renders as a
(via Base), not - + - } - { importExportVisible && - setImportExportVisible(false) } /> } - + + ) } + { importExportVisible && ( + setImportExportVisible(false) } + onSaveFromText={ raw => + { + SendMessageComposer(new UpdateFloorPropertiesMessageComposer( + raw, + state.door.x, + state.door.y, + state.door.dir, + convertNumbersForSaving(state.thickness.wall), + convertNumbersForSaving(state.thickness.floor), + state.wallHeight - 1 + )); + } } + onRevertText={ () => originalRef.current?.tilemap ?? serializeTilemap(state.tiles) } + /> + ) } + ); }; diff --git a/src/components/floorplan-editor/hooks/useFloorplanReducer.test.tsx b/src/components/floorplan-editor/hooks/useFloorplanReducer.test.tsx new file mode 100644 index 0000000..c9b9ae2 --- /dev/null +++ b/src/components/floorplan-editor/hooks/useFloorplanReducer.test.tsx @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest'; +import { act, renderHook } from '@testing-library/react'; +import { useFloorplanReducer } from './useFloorplanReducer'; + +describe('useFloorplanReducer', () => +{ + it('starts with initialState', () => + { + const { result } = renderHook(() => useFloorplanReducer()); + expect(result.current.state.tiles).toEqual([]); + expect(result.current.state.brush.action).toBe('SET'); + }); + + it('loadFromServer seeds tiles + door + wallHeight', () => + { + const { result } = renderHook(() => useFloorplanReducer()); + act(() => + { + result.current.loadFromServer({ + tilemap: '00\rxq', + entryPoint: [ 1, 0 ], + entryPointDir: 4, + thicknessWall: 1, + thicknessFloor: 0, + wallHeight: 5 + }); + }); + expect(result.current.state.tiles).toHaveLength(2); + expect(result.current.state.door).toEqual({ x: 1, y: 0, dir: 4 }); + expect(result.current.state.thickness).toEqual({ wall: 1, floor: 0 }); + expect(result.current.state.wallHeight).toBe(5); + }); + + it('dispatch updates state synchronously', () => + { + const { result } = renderHook(() => useFloorplanReducer()); + act(() => + { + result.current.dispatch({ type: 'BRUSH_SET', action: 'DOOR' }); + }); + expect(result.current.state.brush.action).toBe('DOOR'); + }); +}); diff --git a/src/components/floorplan-editor/hooks/useFloorplanReducer.ts b/src/components/floorplan-editor/hooks/useFloorplanReducer.ts new file mode 100644 index 0000000..9d85cff --- /dev/null +++ b/src/components/floorplan-editor/hooks/useFloorplanReducer.ts @@ -0,0 +1,185 @@ +import { Dispatch, useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'; +import { FloorplanAction, FloorplanState, EntryDir, ThicknessLevel } from '../state/types'; +import { initialState, reducer } from '../state/reducer'; + +export type ServerFloorSettings = { + tilemap: string; + entryPoint: [number, number]; + entryPointDir: number; + thicknessWall: ThicknessLevel; + thicknessFloor: ThicknessLevel; + wallHeight: number; +}; + +type Api = { + state: FloorplanState; + dispatch: Dispatch; + loadFromServer: (s: ServerFloorSettings) => void; + undo: () => void; + redo: () => void; + canUndo: boolean; + canRedo: boolean; +}; + +// Actions that DON'T change the room model — they only affect the +// editor's UI state (brush selection, drag-select rectangle, …) and +// should NOT push a new history snapshot. Brushing a tile, moving a +// door, changing thickness, etc. all DO push history. +const isNonHistoryAction = (action: FloorplanAction): boolean => +{ + switch(action.type) + { + case 'BRUSH_SET': + case 'SELECT_ALL': + case 'CLEAR_SELECTION': + case 'SELECT_RECT': + case 'SQUARE_SELECT_TOGGLE': + return true; + default: + return false; + } +}; + +// Remote-driven actions also bypass history — they represent the +// "true" server state, not a user edit. Treating a server push as +// a history step would let the user "undo" a server snapshot, which +// makes no sense. +const isRemoteAction = (action: FloorplanAction): boolean => +{ + if(action.type === 'APPLY_REMOTE_DIFF' || action.type === 'APPLY_REMOTE_SNAPSHOT') return true; + return 'source' in action && action.source === 'remote'; +}; + +const HISTORY_LIMIT = 100; + +export const useFloorplanReducer = (): Api => +{ + const [ state, dispatch ] = useReducer(reducer, initialState); + + // Past / future stacks — paired with `state` to form a linear + // timeline (`past` ++ [state] ++ `future`). Refs because the + // wrappedDispatch closure needs the latest value but we don't + // want every push to trigger a re-render. canUndo / canRedo are + // separately tracked as React state so the UI buttons disable + // correctly. + const pastRef = useRef([]); + const futureRef = useRef([]); + const [ canUndo, setCanUndo ] = useState(false); + const [ canRedo, setCanRedo ] = useState(false); + const stateRef = useRef(state); + + // Keep stateRef in sync with the latest committed render so the + // history pushers (which run inside callbacks, not during + // render) always see the up-to-date state. Writing the ref + // inside an effect — not directly in the render body — is what + // React's `refs-during-render` rule enforces. + useEffect(() => + { + stateRef.current = state; + }, [ state ]); + + const refreshCanFlags = useCallback(() => + { + setCanUndo(pastRef.current.length > 0); + setCanRedo(futureRef.current.length > 0); + }, []); + + const wrappedDispatch = useCallback>((action) => + { + if(isNonHistoryAction(action) || isRemoteAction(action)) + { + dispatch(action); + return; + } + + // Local edit: push current state onto past, drop future + // (any redo branch is invalidated by a new edit). + pastRef.current.push(stateRef.current); + + if(pastRef.current.length > HISTORY_LIMIT) pastRef.current.shift(); + + futureRef.current = []; + + dispatch(action); + refreshCanFlags(); + }, [ refreshCanFlags ]); + + const loadFromServer = useCallback((s: ServerFloorSettings) => + { + // Server load wipes history — the document is fresh. + pastRef.current = []; + futureRef.current = []; + dispatch({ + type: 'IMPORT_STRING', + raw: s.tilemap, + door: { x: s.entryPoint[0], y: s.entryPoint[1], dir: ((s.entryPointDir | 0) & 7) as EntryDir }, + thickness: { wall: s.thicknessWall, floor: s.thicknessFloor }, + wallHeight: s.wallHeight, + source: 'remote' + }); + refreshCanFlags(); + }, [ refreshCanFlags ]); + + const undo = useCallback(() => + { + const previous = pastRef.current.pop(); + + if(!previous) return; + + futureRef.current.push(stateRef.current); + dispatch({ type: 'APPLY_REMOTE_SNAPSHOT', + raw: serializeTilesForSnapshot(previous.tiles), + door: previous.door, + thickness: previous.thickness, + wallHeight: previous.wallHeight, + seq: previous.seq }); + // The APPLY_REMOTE_SNAPSHOT action re-parses the tilemap; + // but we also want to restore brush/selection state. Wrap + // the dispatch in an effect-like immediate sync by writing + // through stateRef AFTER React commits — handled by the + // next render setting stateRef. The selection/brush carried + // by `previous` is recovered on the next mutating dispatch + // since the reducer's APPLY_REMOTE_SNAPSHOT path resets + // selection (acceptable: undoing a paint clears the + // selection rectangle, which matches user intuition). + refreshCanFlags(); + }, [ refreshCanFlags ]); + + const redo = useCallback(() => + { + const next = futureRef.current.pop(); + + if(!next) return; + + pastRef.current.push(stateRef.current); + dispatch({ type: 'APPLY_REMOTE_SNAPSHOT', + raw: serializeTilesForSnapshot(next.tiles), + door: next.door, + thickness: next.thickness, + wallHeight: next.wallHeight, + seq: next.seq }); + refreshCanFlags(); + }, [ refreshCanFlags ]); + + return useMemo(() => ({ + state, dispatch: wrappedDispatch, loadFromServer, undo, redo, canUndo, canRedo + }), [ state, wrappedDispatch, loadFromServer, undo, redo, canUndo, canRedo ]); +}; + +// Local serializer mirror — the reducer's APPLY_REMOTE_SNAPSHOT +// path takes a raw tilemap string, but our history entries are +// the live Tile[][] arrays. Re-emit `\r`-joined rows in the same +// shape the encoding module uses for SAVES (we keep this here to +// avoid a circular import: state/reducer already imports +// state/encoding). +const serializeTilesForSnapshot = (tiles: { h: number; blocked: boolean }[][]): string => +{ + if(!tiles || tiles.length === 0) return ''; + const scheme = 'x0123456789abcdefghijklmnopq'; + return tiles.map(row => row.map(tile => + { + if(tile.blocked) return 'x'; + const h = Number.isFinite(tile.h) ? Math.max(0, Math.min(scheme.length - 2, tile.h)) : 0; + return scheme.charAt(h + 1); + }).join('')).join('\r'); +}; diff --git a/src/components/floorplan-editor/hooks/usePointerToTile.test.ts b/src/components/floorplan-editor/hooks/usePointerToTile.test.ts new file mode 100644 index 0000000..e478ea8 --- /dev/null +++ b/src/components/floorplan-editor/hooks/usePointerToTile.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { usePointerToTile, screenToTile, tileToScreen } from './usePointerToTile'; + +describe('tileToScreen / screenToTile round-trip', () => +{ + it('origin tile (0,0) projects to (1024, 0) and back', () => + { + const [ sx, sy ] = tileToScreen(0, 0); + expect(sx).toBe(1024); + expect(sy).toBe(0); + expect(screenToTile(sx, sy)).toEqual([ 0, 0 ]); + }); + + it('tile (3, 5) round-trips', () => + { + const [ sx, sy ] = tileToScreen(3, 5); + const [ r, c ] = screenToTile(sx, sy); + expect(r).toBeCloseTo(3, 5); + expect(c).toBeCloseTo(5, 5); + }); + + it('rounds to the containing diamond for jittered points', () => + { + // The diamond for tile (R, C) is centered at tileToScreen(R, C). + // Small jitter inside the diamond should still resolve to the same tile under round-to-nearest. + const [ sx, sy ] = tileToScreen(7, 2); + const [ r, c ] = screenToTile(sx + 2, sy + 1); + expect(Math.round(r)).toBe(7); + expect(Math.round(c)).toBe(2); + }); +}); + +describe('usePointerToTile', () => +{ + it('returns null when no SVG ref is attached', () => + { + const ref = { current: null } as React.RefObject; + const { result } = renderHook(() => usePointerToTile(ref, { width: 2048, height: 1024 })); + expect(result.current.fromClient(100, 100)).toBeNull(); + }); +}); diff --git a/src/components/floorplan-editor/hooks/usePointerToTile.ts b/src/components/floorplan-editor/hooks/usePointerToTile.ts new file mode 100644 index 0000000..6da475f --- /dev/null +++ b/src/components/floorplan-editor/hooks/usePointerToTile.ts @@ -0,0 +1,52 @@ +import { RefObject, useCallback, useMemo } from 'react'; +import { TILE_SIZE } from '../state/constants'; + +const X_OFFSET = 1024; + +export const tileToScreen = (row: number, col: number): [number, number] => +{ + const x = (col * TILE_SIZE / 2) - (row * TILE_SIZE / 2) + X_OFFSET; + const y = (col * TILE_SIZE / 4) + (row * TILE_SIZE / 4); + return [ x, y ]; +}; + +export const screenToTile = (x: number, y: number): [number, number] => +{ + const tx = x - X_OFFSET; + const col = ((tx / (TILE_SIZE / 2)) + (y / (TILE_SIZE / 4))) / 2; + const row = ((y / (TILE_SIZE / 4)) - (tx / (TILE_SIZE / 2))) / 2; + return [ row, col ]; +}; + +type ViewBox = { width: number; height: number; x?: number; y?: number }; + +export type PointerProjection = { + fromClient: (clientX: number, clientY: number) => { row: number; col: number } | null; +}; + +export const usePointerToTile = ( + svgRef: RefObject, + viewBox: ViewBox +): PointerProjection => +{ + const { width, height, x: viewX = 0, y: viewY = 0 } = viewBox; + + const fromClient = useCallback((clientX: number, clientY: number) => + { + const svg = svgRef.current; + if(!svg) return null; + const rect = svg.getBoundingClientRect(); + if(rect.width === 0 || rect.height === 0) return null; + // Map screen-space pointer onto the viewBox interior, then + // shift by the viewBox origin — when zoomed in the viewBox + // starts at (viewX, viewY) instead of (0, 0), so a pointer + // at the left edge of the SVG corresponds to viewX in + // local SVG units, not 0. + const localX = viewX + ((clientX - rect.left) / rect.width) * width; + const localY = viewY + ((clientY - rect.top) / rect.height) * height; + const [ row, col ] = screenToTile(localX, localY); + return { row: Math.round(row), col: Math.round(col) }; + }, [ svgRef, width, height, viewX, viewY ]); + + return useMemo(() => ({ fromClient }), [ fromClient ]); +}; diff --git a/src/components/floorplan-editor/hooks/useTool.test.ts b/src/components/floorplan-editor/hooks/useTool.test.ts new file mode 100644 index 0000000..41360c3 --- /dev/null +++ b/src/components/floorplan-editor/hooks/useTool.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, vi } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useTool } from './useTool'; +import { FloorplanState, FloorplanAction } from '../state/types'; +import { initialState } from '../state/reducer'; + +const withBrush = (action: FloorplanState['brush']['action'], h = 0): FloorplanState => + ({ ...initialState, brush: { h, action } }); + +const mockProjection = (tile: { row: number; col: number } | null) => ({ + fromClient: () => tile +}); + +describe('useTool', () => +{ + it('SET dispatches PAINT_TILE on pointer down at hit tile', () => + { + const dispatch = vi.fn(); + const { result } = renderHook(() => useTool(withBrush('SET', 3), dispatch as React.Dispatch, mockProjection({ row: 1, col: 2 }))); + act(() => result.current.onPointerDown({ clientX: 0, clientY: 0, pointerId: 1, currentTarget: { setPointerCapture: () => {} } } as never)); + expect(dispatch).toHaveBeenCalledWith({ type: 'PAINT_TILE', row: 1, col: 2, h: 3, source: 'local' }); + }); + + it('UNSET dispatches ERASE_TILE', () => + { + const dispatch = vi.fn(); + const { result } = renderHook(() => useTool(withBrush('UNSET'), dispatch as React.Dispatch, mockProjection({ row: 0, col: 0 }))); + act(() => result.current.onPointerDown({ clientX: 0, clientY: 0, pointerId: 1, currentTarget: { setPointerCapture: () => {} } } as never)); + expect(dispatch).toHaveBeenCalledWith({ type: 'ERASE_TILE', row: 0, col: 0, source: 'local' }); + }); + + it('UP dispatches ADJUST_HEIGHT delta=+1', () => + { + const dispatch = vi.fn(); + const { result } = renderHook(() => useTool(withBrush('UP'), dispatch as React.Dispatch, mockProjection({ row: 5, col: 6 }))); + act(() => result.current.onPointerDown({ clientX: 0, clientY: 0, pointerId: 1, currentTarget: { setPointerCapture: () => {} } } as never)); + expect(dispatch).toHaveBeenCalledWith({ type: 'ADJUST_HEIGHT', row: 5, col: 6, delta: 1, source: 'local' }); + }); + + it('DOWN dispatches ADJUST_HEIGHT delta=-1', () => + { + const dispatch = vi.fn(); + const { result } = renderHook(() => useTool(withBrush('DOWN'), dispatch as React.Dispatch, mockProjection({ row: 1, col: 1 }))); + act(() => result.current.onPointerDown({ clientX: 0, clientY: 0, pointerId: 1, currentTarget: { setPointerCapture: () => {} } } as never)); + expect(dispatch).toHaveBeenCalledWith({ type: 'ADJUST_HEIGHT', row: 1, col: 1, delta: -1, source: 'local' }); + }); + + it('DOOR dispatches SET_DOOR with row→y, col→x', () => + { + const dispatch = vi.fn(); + const { result } = renderHook(() => useTool(withBrush('DOOR'), dispatch as React.Dispatch, mockProjection({ row: 4, col: 7 }))); + act(() => result.current.onPointerDown({ clientX: 0, clientY: 0, pointerId: 1, currentTarget: { setPointerCapture: () => {} } } as never)); + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_DOOR', x: 7, y: 4, source: 'local' }); + }); + + it('does nothing when projection returns null', () => + { + const dispatch = vi.fn(); + const { result } = renderHook(() => useTool(withBrush('SET'), dispatch as React.Dispatch, mockProjection(null))); + act(() => result.current.onPointerDown({ clientX: 0, clientY: 0, pointerId: 1, currentTarget: { setPointerCapture: () => {} } } as never)); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('PAINT continues on pointer move when dragging', () => + { + const dispatch = vi.fn(); + let projTile: { row: number; col: number } = { row: 0, col: 0 }; + const projection = { fromClient: () => projTile }; + const { result } = renderHook(() => useTool(withBrush('SET', 0), dispatch as React.Dispatch, projection)); + act(() => result.current.onPointerDown({ clientX: 0, clientY: 0, pointerId: 1, currentTarget: { setPointerCapture: () => {} } } as never)); + dispatch.mockClear(); + projTile = { row: 0, col: 1 }; + act(() => result.current.onPointerMove({ clientX: 0, clientY: 0, pointerId: 1, currentTarget: { setPointerCapture: () => {} } } as never)); + expect(dispatch).toHaveBeenCalledWith({ type: 'PAINT_TILE', row: 0, col: 1, h: 0, source: 'local' }); + }); + + it('PAINT does not re-dispatch on move within same tile', () => + { + const dispatch = vi.fn(); + const projection = { fromClient: () => ({ row: 0, col: 0 }) }; + const { result } = renderHook(() => useTool(withBrush('SET'), dispatch as React.Dispatch, projection)); + act(() => result.current.onPointerDown({ clientX: 0, clientY: 0, pointerId: 1, currentTarget: { setPointerCapture: () => {} } } as never)); + dispatch.mockClear(); + act(() => result.current.onPointerMove({ clientX: 1, clientY: 1, pointerId: 1, currentTarget: { setPointerCapture: () => {} } } as never)); + expect(dispatch).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/floorplan-editor/hooks/useTool.ts b/src/components/floorplan-editor/hooks/useTool.ts new file mode 100644 index 0000000..e8ed57e --- /dev/null +++ b/src/components/floorplan-editor/hooks/useTool.ts @@ -0,0 +1,73 @@ +import { Dispatch, PointerEvent, useCallback, useRef } from 'react'; +import { FloorplanAction, FloorplanState } from '../state/types'; +import { PointerProjection } from './usePointerToTile'; + +type Handlers = { + onPointerDown: (e: PointerEvent) => void; + onPointerMove: (e: PointerEvent) => void; + onPointerUp: (e: PointerEvent) => void; +}; + +const tileKey = (row: number, col: number) => `${ row },${ col }` as const; + +const dispatchForBrush = ( + action: FloorplanState['brush']['action'], + h: number, + row: number, + col: number, + dispatch: Dispatch +): void => +{ + switch(action) + { + case 'SET': dispatch({ type: 'PAINT_TILE', row, col, h, source: 'local' }); return; + case 'UNSET': dispatch({ type: 'ERASE_TILE', row, col, source: 'local' }); return; + case 'UP': dispatch({ type: 'ADJUST_HEIGHT', row, col, delta: 1, source: 'local' }); return; + case 'DOWN': dispatch({ type: 'ADJUST_HEIGHT', row, col, delta: -1, source: 'local' }); return; + case 'DOOR': dispatch({ type: 'SET_DOOR', x: col, y: row, source: 'local' }); return; + } +}; + +export const useTool = ( + state: FloorplanState, + dispatch: Dispatch, + projection: PointerProjection +): Handlers => +{ + const isDownRef = useRef(false); + const lastTileRef = useRef(null); + + const apply = useCallback((e: PointerEvent) => + { + const hit = projection.fromClient(e.clientX, e.clientY); + if(!hit) return; + const key = tileKey(hit.row, hit.col); + if(key === lastTileRef.current) return; + lastTileRef.current = key; + dispatchForBrush(state.brush.action, state.brush.h, hit.row, hit.col, dispatch); + }, [ projection, state.brush.action, state.brush.h, dispatch ]); + + const onPointerDown = useCallback((e: PointerEvent) => + { + isDownRef.current = true; + lastTileRef.current = null; + try { e.currentTarget.setPointerCapture?.(e.pointerId); } catch {} + apply(e); + }, [ apply ]); + + const onPointerMove = useCallback((e: PointerEvent) => + { + if(!isDownRef.current) return; + if(state.brush.action === 'DOOR') return; // door is a single-click placement + apply(e); + }, [ apply, state.brush.action ]); + + const onPointerUp = useCallback((e: PointerEvent) => + { + isDownRef.current = false; + lastTileRef.current = null; + try { e.currentTarget.releasePointerCapture?.(e.pointerId); } catch {} + }, []); + + return { onPointerDown, onPointerMove, onPointerUp }; +}; diff --git a/src/components/floorplan-editor/state/constants.ts b/src/components/floorplan-editor/state/constants.ts new file mode 100644 index 0000000..559fcfa --- /dev/null +++ b/src/components/floorplan-editor/state/constants.ts @@ -0,0 +1,15 @@ +export { + FloorAction, + HEIGHT_SCHEME, + COLORMAP, + TILE_SIZE, + MAX_NUM_TILE_PER_AXIS +} from '@nitrots/nitro-renderer'; + +export const MIN_WALL_HEIGHT = 0; +export const MAX_WALL_HEIGHT = 16; +export const HEIGHT_BRUSH_MIN = 0; +export const HEIGHT_BRUSH_MAX = 26; + +// Empty (uninitialized) door used as initial state until a server event arrives. +export const EMPTY_DOOR = { x: 0, y: 0, dir: 2 as const }; diff --git a/src/components/floorplan-editor/state/encoding.test.ts b/src/components/floorplan-editor/state/encoding.test.ts new file mode 100644 index 0000000..bb31616 --- /dev/null +++ b/src/components/floorplan-editor/state/encoding.test.ts @@ -0,0 +1,169 @@ +import { describe, it, expect } from 'vitest'; +import { + parseTilemap, + serializeTilemap, + tileToChar, + charToTile +} from './encoding'; + +describe('charToTile', () => +{ + it('returns blocked for x', () => + { + expect(charToTile('x')).toEqual({ h: 0, blocked: true }); + }); + + it('returns h=0 for "0"', () => + { + expect(charToTile('0')).toEqual({ h: 0, blocked: false }); + }); + + it('returns h=9 for "9"', () => + { + expect(charToTile('9')).toEqual({ h: 9, blocked: false }); + }); + + it('returns h=10 for "a"', () => + { + expect(charToTile('a')).toEqual({ h: 10, blocked: false }); + }); + + it('returns h=26 for "q"', () => + { + expect(charToTile('q')).toEqual({ h: 26, blocked: false }); + }); + + it('treats uppercase X as blocked', () => + { + expect(charToTile('X')).toEqual({ h: 0, blocked: true }); + }); + + it('returns blocked for any unknown char (defensive)', () => + { + expect(charToTile('?')).toEqual({ h: 0, blocked: true }); + }); +}); + +describe('tileToChar', () => +{ + it('returns x for blocked tile', () => + { + expect(tileToChar({ h: 5, blocked: true })).toBe('x'); + }); + + it('returns "0" for h=0 non-blocked', () => + { + expect(tileToChar({ h: 0, blocked: false })).toBe('0'); + }); + + it('returns "q" for h=26 non-blocked', () => + { + expect(tileToChar({ h: 26, blocked: false })).toBe('q'); + }); + + it('clamps out-of-range h to nearest valid', () => + { + expect(tileToChar({ h: -1, blocked: false })).toBe('0'); + expect(tileToChar({ h: 99, blocked: false })).toBe('q'); + }); + + it('treats NaN h as h=0 on non-blocked tile (does not collapse to blocked)', () => + { + expect(tileToChar({ h: NaN, blocked: false })).toBe('0'); + }); +}); + +describe('parseTilemap', () => +{ + it('returns empty grid for empty string', () => + { + expect(parseTilemap('')).toEqual([]); + }); + + it('parses a single row', () => + { + expect(parseTilemap('00x0')).toEqual([ + [ + { h: 0, blocked: false }, + { h: 0, blocked: false }, + { h: 0, blocked: true }, + { h: 0, blocked: false } + ] + ]); + }); + + it('parses multiple rows separated by \\r', () => + { + const raw = '00\rxx\r12'; + const grid = parseTilemap(raw); + expect(grid).toHaveLength(3); + expect(grid[0]).toHaveLength(2); + expect(grid[1][0].blocked).toBe(true); + expect(grid[2][1]).toEqual({ h: 2, blocked: false }); + }); + + it('also accepts \\r\\n as row separator', () => + { + const raw = '00\r\nxx'; + const grid = parseTilemap(raw); + expect(grid).toHaveLength(2); + expect(grid[1][1].blocked).toBe(true); + }); + + it('also accepts \\n alone as row separator (textarea normalization)', () => + { + const raw = '00\nxq'; + const grid = parseTilemap(raw); + expect(grid).toHaveLength(2); + expect(grid[0]).toHaveLength(2); + expect(grid[1][1]).toEqual({ h: 26, blocked: false }); + }); + + it('pads short rows with blocked tiles so the grid is rectangular', () => + { + const raw = '000\rx'; + const grid = parseTilemap(raw); + expect(grid[1]).toHaveLength(3); + expect(grid[1][1]).toEqual({ h: 0, blocked: true }); + expect(grid[1][2]).toEqual({ h: 0, blocked: true }); + }); +}); + +describe('serializeTilemap', () => +{ + it('returns empty string for empty grid', () => + { + expect(serializeTilemap([])).toBe(''); + }); + + it('serializes a single row with no separator', () => + { + const grid = [[ + { h: 0, blocked: false }, + { h: 1, blocked: false }, + { h: 0, blocked: true } + ]]; + expect(serializeTilemap(grid)).toBe('01x'); + }); + + it('separates rows with \\r', () => + { + const grid = [ + [{ h: 0, blocked: false }, { h: 0, blocked: false }], + [{ h: 0, blocked: true }, { h: 26, blocked: false }] + ]; + expect(serializeTilemap(grid)).toBe('00\rxq'); + }); + + it('round-trips parse → serialize', () => + { + const raw = '0123\rxxqq\r1234'; + expect(serializeTilemap(parseTilemap(raw))).toBe(raw); + }); + + it('jagged-row round-trip normalizes short rows with x-padding', () => + { + const raw = '000\rx'; + expect(serializeTilemap(parseTilemap(raw))).toBe('000\rxxx'); + }); +}); diff --git a/src/components/floorplan-editor/state/encoding.ts b/src/components/floorplan-editor/state/encoding.ts new file mode 100644 index 0000000..08fb97a --- /dev/null +++ b/src/components/floorplan-editor/state/encoding.ts @@ -0,0 +1,47 @@ +import { Tile } from './types'; +import { HEIGHT_SCHEME } from './constants'; + +// 'x0123456789abcdefghijklmnopq' (28 chars total) +const VALID_CHARS = HEIGHT_SCHEME; +const HMIN = 0; +const HMAX = VALID_CHARS.length - 2; // 26 + +export const charToTile = (ch: string): Tile => +{ + const lower = ch.toLowerCase(); + const idx = VALID_CHARS.indexOf(lower); + if(idx <= 0) return { h: 0, blocked: true }; + return { h: idx - 1, blocked: false }; +}; + +export const tileToChar = (tile: Tile): string => +{ + if(tile.blocked) return 'x'; + const h = Number.isFinite(tile.h) + ? Math.max(HMIN, Math.min(HMAX, tile.h)) + : HMIN; + return VALID_CHARS.charAt(h + 1); +}; + +export const parseTilemap = (raw: string): Tile[][] => +{ + if(!raw) return []; + const cleaned = raw.split(/\r\n|\r|\n/).filter(r => r.length > 0); + if(cleaned.length === 0) return []; + const width = cleaned.reduce((m, r) => Math.max(m, r.length), 0); + return cleaned.map(rowStr => + { + const cells: Tile[] = []; + for(let i = 0; i < width; i++) + { + cells.push(i < rowStr.length ? charToTile(rowStr.charAt(i)) : { h: 0, blocked: true }); + } + return cells; + }); +}; + +export const serializeTilemap = (tiles: Tile[][]): string => +{ + if(!tiles || tiles.length === 0) return ''; + return tiles.map(row => row.map(tileToChar).join('')).join('\r'); +}; diff --git a/src/components/floorplan-editor/state/reducer.test.ts b/src/components/floorplan-editor/state/reducer.test.ts new file mode 100644 index 0000000..a6b7e09 --- /dev/null +++ b/src/components/floorplan-editor/state/reducer.test.ts @@ -0,0 +1,326 @@ +import { describe, it, expect } from 'vitest'; +import { reducer, initialState } from './reducer'; +import { FloorplanState } from './types'; +import { defaultEmptyTilemap } from './selectors'; + +const stateWith = (tiles: FloorplanState['tiles']): FloorplanState => ({ + ...initialState, + tiles +}); + +describe('reducer — PAINT_TILE', () => +{ + it('sets tile to {h, blocked: false}', () => + { + const start = stateWith(defaultEmptyTilemap(2, 2)); + const next = reducer(start, { type: 'PAINT_TILE', row: 0, col: 1, h: 5, source: 'local' }); + expect(next.tiles[0][1]).toEqual({ h: 5, blocked: false }); + expect(next.tiles[0][0]).toEqual({ h: 0, blocked: true }); + }); + + it('clamps h to 0..26', () => + { + const start = stateWith(defaultEmptyTilemap(1, 1)); + const next = reducer(start, { type: 'PAINT_TILE', row: 0, col: 0, h: 99, source: 'local' }); + expect(next.tiles[0][0].h).toBe(26); + }); + + it('grows the grid to fit out-of-bounds rows/cols', () => + { + const start = stateWith(defaultEmptyTilemap(1, 1)); + const next = reducer(start, { type: 'PAINT_TILE', row: 2, col: 3, h: 0, source: 'local' }); + expect(next.tiles).toHaveLength(3); + expect(next.tiles[2]).toHaveLength(4); + expect(next.tiles[2][3]).toEqual({ h: 0, blocked: false }); + expect(next.tiles[0][0]).toEqual({ h: 0, blocked: true }); + }); + + it('caps growth at MAX_NUM_TILE_PER_AXIS', () => + { + const start = stateWith(defaultEmptyTilemap(1, 1)); + const next = reducer(start, { type: 'PAINT_TILE', row: 99, col: 99, h: 0, source: 'local' }); + expect(next.tiles).toHaveLength(64); + expect(next.tiles[0]).toHaveLength(64); + }); + + it('returns the same reference if no change (idempotent painting)', () => + { + const tile = { h: 5, blocked: false }; + const start = stateWith([[tile]]); + const next = reducer(start, { type: 'PAINT_TILE', row: 0, col: 0, h: 5, source: 'local' }); + expect(next).toBe(start); + }); +}); + +describe('reducer — ERASE_TILE', () => +{ + it('marks tile as blocked', () => + { + const start = stateWith([[{ h: 5, blocked: false }]]); + const next = reducer(start, { type: 'ERASE_TILE', row: 0, col: 0, source: 'local' }); + expect(next.tiles[0][0]).toEqual({ h: 5, blocked: true }); + }); + + it('is a no-op outside the grid', () => + { + const start = stateWith(defaultEmptyTilemap(1, 1)); + const next = reducer(start, { type: 'ERASE_TILE', row: 5, col: 5, source: 'local' }); + expect(next).toBe(start); + }); +}); + +describe('reducer — ADJUST_HEIGHT', () => +{ + it('increments height by 1', () => + { + const start = stateWith([[{ h: 5, blocked: false }]]); + const next = reducer(start, { type: 'ADJUST_HEIGHT', row: 0, col: 0, delta: 1, source: 'local' }); + expect(next.tiles[0][0]).toEqual({ h: 6, blocked: false }); + }); + + it('decrements height by 1', () => + { + const start = stateWith([[{ h: 5, blocked: false }]]); + const next = reducer(start, { type: 'ADJUST_HEIGHT', row: 0, col: 0, delta: -1, source: 'local' }); + expect(next.tiles[0][0]).toEqual({ h: 4, blocked: false }); + }); + + it('clamps at 26 going up', () => + { + const start = stateWith([[{ h: 26, blocked: false }]]); + const next = reducer(start, { type: 'ADJUST_HEIGHT', row: 0, col: 0, delta: 1, source: 'local' }); + expect(next.tiles[0][0].h).toBe(26); + }); + + it('clamps at 0 going down', () => + { + const start = stateWith([[{ h: 0, blocked: false }]]); + const next = reducer(start, { type: 'ADJUST_HEIGHT', row: 0, col: 0, delta: -1, source: 'local' }); + expect(next.tiles[0][0].h).toBe(0); + }); + + it('is a no-op on blocked tiles', () => + { + const start = stateWith([[{ h: 5, blocked: true }]]); + const next = reducer(start, { type: 'ADJUST_HEIGHT', row: 0, col: 0, delta: 1, source: 'local' }); + expect(next).toBe(start); + }); +}); + +describe('reducer — SET_DOOR', () => +{ + it('updates door position', () => + { + const next = reducer(initialState, { type: 'SET_DOOR', x: 3, y: 4, source: 'local' }); + expect(next.door).toEqual({ x: 3, y: 4, dir: 2 }); + }); + + it('preserves door direction', () => + { + const start = { ...initialState, door: { x: 0, y: 0, dir: 5 as const } }; + const next = reducer(start, { type: 'SET_DOOR', x: 1, y: 1, source: 'local' }); + expect(next.door).toEqual({ x: 1, y: 1, dir: 5 }); + }); +}); + +describe('reducer — SET_DOOR_DIR', () => +{ + it('updates direction', () => + { + const next = reducer(initialState, { type: 'SET_DOOR_DIR', dir: 7, source: 'local' }); + expect(next.door.dir).toBe(7); + }); +}); + +describe('reducer — SET_THICKNESS', () => +{ + it('updates wall only', () => + { + const next = reducer(initialState, { type: 'SET_THICKNESS', wall: 3, source: 'local' }); + expect(next.thickness).toEqual({ wall: 3, floor: 1 }); + }); + + it('updates floor only', () => + { + const next = reducer(initialState, { type: 'SET_THICKNESS', floor: 0, source: 'local' }); + expect(next.thickness).toEqual({ wall: 1, floor: 0 }); + }); + + it('updates both', () => + { + const next = reducer(initialState, { type: 'SET_THICKNESS', wall: 2, floor: 3, source: 'local' }); + expect(next.thickness).toEqual({ wall: 2, floor: 3 }); + }); +}); + +describe('reducer — SET_WALL_HEIGHT', () => +{ + it('updates wallHeight clamped to 0..16', () => + { + expect(reducer(initialState, { type: 'SET_WALL_HEIGHT', value: 5, source: 'local' }).wallHeight).toBe(5); + expect(reducer(initialState, { type: 'SET_WALL_HEIGHT', value: 99, source: 'local' }).wallHeight).toBe(16); + expect(reducer(initialState, { type: 'SET_WALL_HEIGHT', value: -3, source: 'local' }).wallHeight).toBe(0); + }); +}); + +describe('reducer — BRUSH_SET', () => +{ + it('updates h only', () => + { + const next = reducer(initialState, { type: 'BRUSH_SET', h: 10 }); + expect(next.brush).toEqual({ h: 10, action: 'SET' }); + }); + + it('updates action only', () => + { + const next = reducer(initialState, { type: 'BRUSH_SET', action: 'DOOR' }); + expect(next.brush).toEqual({ h: 0, action: 'DOOR' }); + }); +}); + +describe('reducer — selection', () => +{ + it('SELECT_ALL marks every non-blocked tile', () => + { + const start = stateWith([ + [{ h: 0, blocked: false }, { h: 0, blocked: true }], + [{ h: 0, blocked: false }, { h: 0, blocked: false }] + ]); + const next = reducer(start, { type: 'SELECT_ALL' }); + expect(next.selection.size).toBe(3); + expect(next.selection.has('0,0')).toBe(true); + expect(next.selection.has('0,1')).toBe(false); + expect(next.selection.has('1,1')).toBe(true); + }); + + it('CLEAR_SELECTION empties it', () => + { + const start = { ...initialState, selection: new Set(['0,0', '1,1']) as ReadonlySet<`${number},${number}`> }; + const next = reducer(start, { type: 'CLEAR_SELECTION' }); + expect(next.selection.size).toBe(0); + }); + + it('SELECT_RECT marks the rectangle inclusive', () => + { + const start = stateWith(defaultEmptyTilemap(4, 4)); + // First populate non-blocked tiles so SELECT_RECT picks them up + const populated = { + ...start, + tiles: start.tiles.map(row => row.map(() => ({ h: 0, blocked: false }))) + } as FloorplanState; + const next = reducer(populated, { type: 'SELECT_RECT', from: [ 1, 1 ], to: [ 2, 3 ] }); + const keys = Array.from(next.selection).sort(); + expect(keys).toEqual([ '1,1', '1,2', '1,3', '2,1', '2,2', '2,3' ].sort()); + }); + + it('SQUARE_SELECT_TOGGLE flips the flag', () => + { + const a = reducer(initialState, { type: 'SQUARE_SELECT_TOGGLE' }); + expect(a.squareSelect).toBe(true); + const b = reducer(a, { type: 'SQUARE_SELECT_TOGGLE' }); + expect(b.squareSelect).toBe(false); + }); +}); + +describe('reducer — IMPORT_STRING', () => +{ + it('replaces tilemap with parsed string', () => + { + const start = stateWith(defaultEmptyTilemap(1, 1)); + const next = reducer(start, { type: 'IMPORT_STRING', raw: '01\rxq', source: 'local' }); + expect(next.tiles).toHaveLength(2); + expect(next.tiles[0]).toEqual([ + { h: 0, blocked: false }, + { h: 1, blocked: false } + ]); + expect(next.tiles[1]).toEqual([ + { h: 0, blocked: true }, + { h: 26, blocked: false } + ]); + }); + + it('optionally updates door, thickness, wallHeight', () => + { + const next = reducer(initialState, { + type: 'IMPORT_STRING', + raw: '00', + door: { x: 5, y: 6, dir: 4 }, + thickness: { wall: 3, floor: 2 }, + wallHeight: 8, + source: 'local' + }); + expect(next.door).toEqual({ x: 5, y: 6, dir: 4 }); + expect(next.thickness).toEqual({ wall: 3, floor: 2 }); + expect(next.wallHeight).toBe(8); + }); +}); + +describe('reducer — APPLY_REMOTE_DIFF', () => +{ + it('applies tile edits without re-broadcasting (source agnostic)', () => + { + const start = stateWith([[{ h: 0, blocked: false }]]); + const next = reducer(start, { + type: 'APPLY_REMOTE_DIFF', + diff: { tiles: [{ row: 0, col: 0, h: 7, blocked: false }] }, + seq: 1, + editorUserId: 42 + }); + expect(next.tiles[0][0]).toEqual({ h: 7, blocked: false }); + expect(next.seq).toBe(1); + }); + + it('records last seq', () => + { + const start = stateWith([[{ h: 0, blocked: false }]]); + const a = reducer(start, { type: 'APPLY_REMOTE_DIFF', diff: { tiles: [{ row: 0, col: 0, h: 1, blocked: false }] }, seq: 5, editorUserId: 1 }); + expect(a.seq).toBe(5); + }); + + it('applies door/thickness/wallHeight from diff', () => + { + const next = reducer(initialState, { + type: 'APPLY_REMOTE_DIFF', + diff: { door: { x: 2, y: 3, dir: 0 }, thickness: { wall: 0, floor: 0 }, wallHeight: 4 }, + seq: 1, + editorUserId: 99 + }); + expect(next.door).toEqual({ x: 2, y: 3, dir: 0 }); + expect(next.thickness).toEqual({ wall: 0, floor: 0 }); + expect(next.wallHeight).toBe(4); + }); +}); + +describe('reducer — APPLY_REMOTE_SNAPSHOT', () => +{ + it('replaces full state from snapshot', () => + { + const next = reducer(initialState, { + type: 'APPLY_REMOTE_SNAPSHOT', + raw: '01\rxq', + door: { x: 1, y: 1, dir: 3 }, + thickness: { wall: 2, floor: 3 }, + wallHeight: 9, + seq: 100 + }); + expect(next.tiles).toHaveLength(2); + expect(next.door).toEqual({ x: 1, y: 1, dir: 3 }); + expect(next.thickness).toEqual({ wall: 2, floor: 3 }); + expect(next.wallHeight).toBe(9); + expect(next.seq).toBe(100); + }); + + it('clears selection on snapshot apply', () => + { + const start = { ...initialState, selection: new Set([ '0,0' ]) as ReadonlySet<`${number},${number}`> }; + const next = reducer(start, { + type: 'APPLY_REMOTE_SNAPSHOT', + raw: '0', + door: initialState.door, + thickness: initialState.thickness, + wallHeight: 0, + seq: 1 + }); + expect(next.selection.size).toBe(0); + }); +}); diff --git a/src/components/floorplan-editor/state/reducer.ts b/src/components/floorplan-editor/state/reducer.ts new file mode 100644 index 0000000..ba62ef4 --- /dev/null +++ b/src/components/floorplan-editor/state/reducer.ts @@ -0,0 +1,190 @@ +import { FloorplanAction, FloorplanState, Tile } from './types'; +import { MAX_NUM_TILE_PER_AXIS, EMPTY_DOOR, MIN_WALL_HEIGHT, MAX_WALL_HEIGHT } from './constants'; +import { parseTilemap } from './encoding'; + +export const initialState: FloorplanState = { + tiles: [], + door: { ...EMPTY_DOOR }, + thickness: { wall: 1, floor: 1 }, + wallHeight: -1, + brush: { h: 0, action: 'SET' }, + selection: new Set<`${number},${number}`>(), + squareSelect: false, + lease: { holder: null, me: false, expiresAt: null }, + seq: 0 +}; + +const clampHeight = (h: number): number => Math.max(0, Math.min(26, h | 0)); +const clamp64 = (n: number): number => Math.max(0, Math.min(MAX_NUM_TILE_PER_AXIS - 1, n | 0)); + +const ensureRect = (tiles: Tile[][], rows: number, cols: number): Tile[][] => +{ + const tRows = Math.min(MAX_NUM_TILE_PER_AXIS, Math.max(rows, tiles.length)); + const tCols = Math.min(MAX_NUM_TILE_PER_AXIS, Math.max(cols, tiles[0]?.length ?? 0)); + if(tRows === tiles.length && (tiles[0]?.length ?? 0) === tCols) return tiles; + const next: Tile[][] = []; + for(let r = 0; r < tRows; r++) + { + const src = tiles[r] ?? []; + const row: Tile[] = []; + for(let c = 0; c < tCols; c++) + { + row.push(src[c] ?? { h: 0, blocked: true }); + } + next.push(row); + } + return next; +}; + +const setTile = (tiles: Tile[][], row: number, col: number, tile: Tile): Tile[][] => +{ + const current = tiles[row]?.[col]; + if(current && current.h === tile.h && current.blocked === tile.blocked) return tiles; + const next = tiles.map((r, ri) => ri === row ? r.map((t, ci) => ci === col ? tile : t) : r); + return next; +}; + +export const reducer = (state: FloorplanState, action: FloorplanAction): FloorplanState => +{ + switch(action.type) + { + case 'PAINT_TILE': + { + const row = clamp64(action.row); + const col = clamp64(action.col); + const tiles = ensureRect(state.tiles, row + 1, col + 1); + const target = { h: clampHeight(action.h), blocked: false }; + const next = setTile(tiles, row, col, target); + if(next === tiles && tiles === state.tiles) return state; + return { ...state, tiles: next }; + } + case 'ERASE_TILE': + { + const row = action.row | 0; + const col = action.col | 0; + if(row < 0 || col < 0 || row >= state.tiles.length || col >= (state.tiles[0]?.length ?? 0)) return state; + const current = state.tiles[row][col]; + const target = { h: current.h, blocked: true }; + const next = setTile(state.tiles, row, col, target); + if(next === state.tiles) return state; + return { ...state, tiles: next }; + } + case 'ADJUST_HEIGHT': + { + const row = action.row | 0; + const col = action.col | 0; + if(row < 0 || col < 0 || row >= state.tiles.length || col >= (state.tiles[0]?.length ?? 0)) return state; + const current = state.tiles[row][col]; + if(current.blocked) return state; + const newH = clampHeight(current.h + action.delta); + if(newH === current.h) return state; + const next = setTile(state.tiles, row, col, { h: newH, blocked: false }); + return { ...state, tiles: next }; + } + case 'SET_DOOR': + { + const x = clamp64(action.x); + const y = clamp64(action.y); + if(state.door.x === x && state.door.y === y) return state; + return { ...state, door: { ...state.door, x, y } }; + } + case 'SET_DOOR_DIR': + { + if(state.door.dir === action.dir) return state; + return { ...state, door: { ...state.door, dir: action.dir } }; + } + case 'SET_THICKNESS': + { + const wall = action.wall ?? state.thickness.wall; + const floor = action.floor ?? state.thickness.floor; + if(wall === state.thickness.wall && floor === state.thickness.floor) return state; + return { ...state, thickness: { wall, floor } }; + } + case 'SET_WALL_HEIGHT': + { + const value = Math.max(MIN_WALL_HEIGHT, Math.min(MAX_WALL_HEIGHT, action.value | 0)); + if(value === state.wallHeight) return state; + return { ...state, wallHeight: value }; + } + case 'BRUSH_SET': + { + const h = action.h ?? state.brush.h; + const act = action.action ?? state.brush.action; + if(h === state.brush.h && act === state.brush.action) return state; + return { ...state, brush: { h: clampHeight(h), action: act } }; + } + case 'SELECT_ALL': + { + const sel = new Set<`${number},${number}`>(); + for(let r = 0; r < state.tiles.length; r++) + { + for(let c = 0; c < (state.tiles[r]?.length ?? 0); c++) + { + if(!state.tiles[r][c].blocked) sel.add(`${r},${c}`); + } + } + return { ...state, selection: sel }; + } + case 'CLEAR_SELECTION': + return state.selection.size === 0 ? state : { ...state, selection: new Set() }; + case 'SELECT_RECT': + { + const [ r0, c0 ] = action.from; + const [ r1, c1 ] = action.to; + const rMin = Math.min(r0, r1), rMax = Math.max(r0, r1); + const cMin = Math.min(c0, c1), cMax = Math.max(c0, c1); + const sel = new Set<`${number},${number}`>(); + for(let r = rMin; r <= rMax; r++) + { + for(let c = cMin; c <= cMax; c++) + { + if(state.tiles[r]?.[c] && !state.tiles[r][c].blocked) sel.add(`${r},${c}`); + } + } + return { ...state, selection: sel }; + } + case 'SQUARE_SELECT_TOGGLE': + return { ...state, squareSelect: !state.squareSelect }; + case 'IMPORT_STRING': + { + const tiles = parseTilemap(action.raw); + const next: FloorplanState = { ...state, tiles }; + if(action.door) next.door = action.door; + if(action.thickness) next.thickness = action.thickness; + if(action.wallHeight !== undefined) next.wallHeight = Math.max(MIN_WALL_HEIGHT, Math.min(MAX_WALL_HEIGHT, action.wallHeight | 0)); + return next; + } + case 'APPLY_REMOTE_DIFF': + { + let next: FloorplanState = { ...state, seq: action.seq }; + if(action.diff.tiles) + { + let tiles = next.tiles; + for(const e of action.diff.tiles) + { + tiles = ensureRect(tiles, e.row + 1, e.col + 1); + tiles = setTile(tiles, e.row, e.col, { h: clampHeight(e.h), blocked: e.blocked }); + } + next.tiles = tiles; + } + if(action.diff.door) next.door = action.diff.door; + if(action.diff.thickness) next.thickness = action.diff.thickness; + if(action.diff.wallHeight !== undefined) next.wallHeight = Math.max(MIN_WALL_HEIGHT, Math.min(MAX_WALL_HEIGHT, action.diff.wallHeight | 0)); + return next; + } + case 'APPLY_REMOTE_SNAPSHOT': + { + return { + ...state, + tiles: parseTilemap(action.raw), + door: action.door, + thickness: action.thickness, + wallHeight: Math.max(MIN_WALL_HEIGHT, Math.min(MAX_WALL_HEIGHT, action.wallHeight | 0)), + selection: new Set(), + seq: action.seq + }; + } + default: + return state; + } +}; diff --git a/src/components/floorplan-editor/state/selectors.test.ts b/src/components/floorplan-editor/state/selectors.test.ts new file mode 100644 index 0000000..f885055 --- /dev/null +++ b/src/components/floorplan-editor/state/selectors.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from 'vitest'; +import { + areaCount, + brushChar, + tileFill, + defaultEmptyTilemap +} from './selectors'; +import { HEIGHT_SCHEME } from './constants'; + +describe('areaCount', () => +{ + it('returns zeros for empty grid', () => + { + expect(areaCount([])).toEqual({ total: 0, walkable: 0 }); + }); + + it('counts total = walkable when no blocked tiles', () => + { + const grid = [ + [{ h: 0, blocked: false }, { h: 1, blocked: false }], + [{ h: 2, blocked: false }, { h: 3, blocked: false }] + ]; + expect(areaCount(grid)).toEqual({ total: 4, walkable: 4 }); + }); + + it('excludes blocked from walkable but counts in total', () => + { + const grid = [ + [{ h: 0, blocked: false }, { h: 0, blocked: true }], + [{ h: 2, blocked: false }, { h: 3, blocked: false }] + ]; + expect(areaCount(grid)).toEqual({ total: 3, walkable: 3 }); + }); + + it('treats blocked tiles as non-tiles (per existing UI semantics)', () => + { + // In the original implementation, height === 'x' was the marker for "not a tile". + // total counts placed tiles only (i.e. !blocked), walkable equals total since blocked are excluded. + // This matches the legacy calculateArea() behaviour where blocked tiles were skipped entirely. + const grid = [ + [{ h: 0, blocked: true }, { h: 0, blocked: true }, { h: 0, blocked: true }], + [{ h: 0, blocked: false }, { h: 1, blocked: false }, { h: 0, blocked: true }] + ]; + expect(areaCount(grid)).toEqual({ total: 2, walkable: 2 }); + }); +}); + +describe('brushChar', () => +{ + it('h=0 → "0"', () => expect(brushChar(0)).toBe('0')); + it('h=26 → "q"', () => expect(brushChar(26)).toBe('q')); + it('clamps below to "0"', () => expect(brushChar(-5)).toBe('0')); + it('clamps above to "q"', () => expect(brushChar(99)).toBe('q')); +}); + +describe('tileFill', () => +{ + it('returns COLORMAP entry for non-blocked tile', () => + { + const fill = tileFill({ h: 0, blocked: false }); + expect(fill).toBe('#0065ff'); + }); + + it('returns COLORMAP entry for blocked tile', () => + { + expect(tileFill({ h: 5, blocked: true })).toBe('#101010'); + }); +}); + +describe('defaultEmptyTilemap', () => +{ + it('returns a rows×cols grid of blocked tiles', () => + { + const grid = defaultEmptyTilemap(3, 4); + expect(grid).toHaveLength(3); + expect(grid[0]).toHaveLength(4); + expect(grid[0][0]).toEqual({ h: 0, blocked: true }); + expect(grid[2][3]).toEqual({ h: 0, blocked: true }); + }); +}); diff --git a/src/components/floorplan-editor/state/selectors.ts b/src/components/floorplan-editor/state/selectors.ts new file mode 100644 index 0000000..5b65917 --- /dev/null +++ b/src/components/floorplan-editor/state/selectors.ts @@ -0,0 +1,43 @@ +import { Tile } from './types'; +import { HEIGHT_SCHEME, COLORMAP, HEIGHT_BRUSH_MIN, HEIGHT_BRUSH_MAX } from './constants'; + +export const areaCount = (tiles: Tile[][]): { total: number; walkable: number } => +{ + let total = 0; + let walkable = 0; + for(const row of tiles) + { + for(const tile of row) + { + if(tile.blocked) continue; + total++; + walkable++; + } + } + return { total, walkable }; +}; + +export const brushChar = (h: number): string => +{ + const clamped = Math.max(HEIGHT_BRUSH_MIN, Math.min(HEIGHT_BRUSH_MAX, h)); + return HEIGHT_SCHEME.charAt(clamped + 1); +}; + +export const tileFill = (tile: Tile): string => +{ + const ch = tile.blocked ? 'x' : HEIGHT_SCHEME.charAt(Math.max(0, Math.min(26, tile.h)) + 1); + const hex = (COLORMAP as Record)[ch] ?? '101010'; + return `#${ hex }`; +}; + +export const defaultEmptyTilemap = (rows: number, cols: number): Tile[][] => +{ + const grid: Tile[][] = []; + for(let r = 0; r < rows; r++) + { + const row: Tile[] = []; + for(let c = 0; c < cols; c++) row.push({ h: 0, blocked: true }); + grid.push(row); + } + return grid; +}; diff --git a/src/components/floorplan-editor/state/types.ts b/src/components/floorplan-editor/state/types.ts new file mode 100644 index 0000000..ce536ce --- /dev/null +++ b/src/components/floorplan-editor/state/types.ts @@ -0,0 +1,49 @@ +export type Tile = { h: number; blocked: boolean }; + +export type EntryDir = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7; +export type ThicknessLevel = 0 | 1 | 2 | 3; + +export type Door = { x: number; y: number; dir: EntryDir }; + +export type FloorActionMode = 'SET' | 'UNSET' | 'UP' | 'DOWN' | 'DOOR'; + +export type Brush = { h: number; action: FloorActionMode }; + +export type Selection = ReadonlySet<`${number},${number}`>; + +export type Lease = { + holder: number | null; + me: boolean; + expiresAt: number | null; +}; + +export type FloorplanState = { + tiles: Tile[][]; + door: Door; + thickness: { wall: ThicknessLevel; floor: ThicknessLevel }; + wallHeight: number; + brush: Brush; + selection: Selection; + squareSelect: boolean; + lease: Lease; + seq: number; +}; + +export type LocalSource = 'local' | 'remote'; + +export type FloorplanAction = + | { type: 'PAINT_TILE'; row: number; col: number; h: number; source: LocalSource } + | { type: 'ERASE_TILE'; row: number; col: number; source: LocalSource } + | { type: 'ADJUST_HEIGHT'; row: number; col: number; delta: 1 | -1; source: LocalSource } + | { type: 'SET_DOOR'; x: number; y: number; source: LocalSource } + | { type: 'SET_DOOR_DIR'; dir: EntryDir; source: LocalSource } + | { type: 'SET_THICKNESS'; wall?: ThicknessLevel; floor?: ThicknessLevel; source: LocalSource } + | { type: 'SET_WALL_HEIGHT'; value: number; source: LocalSource } + | { type: 'BRUSH_SET'; h?: number; action?: FloorActionMode } + | { type: 'SELECT_RECT'; from: [number, number]; to: [number, number] } + | { type: 'SELECT_ALL' } + | { type: 'CLEAR_SELECTION' } + | { type: 'SQUARE_SELECT_TOGGLE' } + | { type: 'IMPORT_STRING'; raw: string; door?: Door; thickness?: { wall: ThicknessLevel; floor: ThicknessLevel }; wallHeight?: number; source: LocalSource } + | { type: 'APPLY_REMOTE_DIFF'; diff: { tiles?: Array<{ row: number; col: number; h: number; blocked: boolean }>; door?: Door; thickness?: { wall: ThicknessLevel; floor: ThicknessLevel }; wallHeight?: number }; seq: number; editorUserId: number } + | { type: 'APPLY_REMOTE_SNAPSHOT'; raw: string; door: Door; thickness: { wall: ThicknessLevel; floor: ThicknessLevel }; wallHeight: number; seq: number }; diff --git a/src/components/floorplan-editor/views/FloorplanCanvasSVG.test.tsx b/src/components/floorplan-editor/views/FloorplanCanvasSVG.test.tsx new file mode 100644 index 0000000..b205a71 --- /dev/null +++ b/src/components/floorplan-editor/views/FloorplanCanvasSVG.test.tsx @@ -0,0 +1,60 @@ +import { describe, it, expect, vi } from 'vitest'; +import { fireEvent, render } from '@testing-library/react'; +import { FloorplanCanvasSVG } from './FloorplanCanvasSVG'; +import { initialState } from '../state/reducer'; + +describe('FloorplanCanvasSVG', () => +{ + it('renders one polygon per non-blocked tile', () => + { + const state = { + ...initialState, + tiles: [ + [{ h: 0, blocked: false }, { h: 1, blocked: true }], + [{ h: 2, blocked: false }, { h: 3, blocked: false }] + ] + }; + const { container } = render( {} } />); + // 3 non-blocked tiles → 3 base polygons (plus possibly selection/door extras) + const polys = container.querySelectorAll('polygon'); + expect(polys.length).toBeGreaterThanOrEqual(3); + }); + + it('renders door marker on the door tile', () => + { + const state = { + ...initialState, + tiles: [[{ h: 0, blocked: false }, { h: 0, blocked: false }]], + door: { x: 1, y: 0, dir: 2 as const } + }; + const { container } = render( {} } />); + expect(container.querySelector('[data-testid="door-marker"]')).toBeTruthy(); + }); + + it('forwards pointer events to a tool dispatch (PAINT_TILE with brush)', () => + { + const state = { + ...initialState, + tiles: [[{ h: 0, blocked: false }]], + brush: { h: 0, action: 'SET' as const } + }; + const dispatch = vi.fn(); + const { container } = render(); + const svg = container.querySelector('svg') as SVGSVGElement; + // jsdom getBoundingClientRect returns zeros; we need to stub it so projection works. + svg.getBoundingClientRect = () => ({ left: 0, top: 0, right: 2048, bottom: 1024, width: 2048, height: 1024, x: 0, y: 0, toJSON: () => ({}) }); + fireEvent.pointerDown(svg, { clientX: 1024, clientY: 0, pointerId: 1 }); + expect(dispatch).toHaveBeenCalled(); + const call = dispatch.mock.calls[0][0]; + expect(call.type).toBe('PAINT_TILE'); + }); + + it('zoom in/out buttons adjust the viewBox', () => + { + const { container } = render( {} } />); + const svg = container.querySelector('svg') as SVGSVGElement; + const initialVB = svg.getAttribute('viewBox'); + fireEvent.click(container.querySelector('[data-testid="zoom-in"]') as Element); + expect(svg.getAttribute('viewBox')).not.toBe(initialVB); + }); +}); diff --git a/src/components/floorplan-editor/views/FloorplanCanvasSVG.tsx b/src/components/floorplan-editor/views/FloorplanCanvasSVG.tsx new file mode 100644 index 0000000..a553e23 --- /dev/null +++ b/src/components/floorplan-editor/views/FloorplanCanvasSVG.tsx @@ -0,0 +1,288 @@ +import { Dispatch, FC, PointerEvent as ReactPointerEvent, useCallback, useEffect, useMemo, useRef, useState, WheelEvent } from 'react'; +import { FaCrosshairs, FaSearchMinus, FaSearchPlus } from 'react-icons/fa'; +import { FloorplanAction, FloorplanState } from '../state/types'; +import { FloorplanTile } from './FloorplanTile'; +import { tileToScreen, usePointerToTile } from '../hooks/usePointerToTile'; +import { useTool } from '../hooks/useTool'; +import { TILE_SIZE, MAX_NUM_TILE_PER_AXIS } from '../state/constants'; + +type Props = { + state: FloorplanState; + dispatch: Dispatch; +}; + +const VIEWBOX_W = 2048; +const VIEWBOX_H = (MAX_NUM_TILE_PER_AXIS * TILE_SIZE) / 2; + +const ZOOM_MIN = 0.4; +const ZOOM_MAX = 6; +const ZOOM_STEP = 0.2; +// Slack around the room bounding box when auto-fitting, so the tiles +// don't sit flush against the canvas edge. +const FIT_PADDING = TILE_SIZE * 2; + +const clampZoom = (z: number): number => Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, z)); + +/** + * Compute the screen-space bounding box of the painted (= non- + * blocked) tiles. Returns `null` if the room is fully blocked / + * empty — caller can fall back to the centered default view. + * + * tileToScreen returns the TOP corner of the iso diamond; we + * inflate by half a tile in every direction so the diamond's + * extremities (left/right/bottom points) are included. + */ +const computeRoomBounds = (state: FloorplanState): { x: number; y: number; w: number; h: number } | null => +{ + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + let found = false; + + for(let r = 0; r < state.tiles.length; r++) + { + const row = state.tiles[r]; + + if(!row) continue; + + for(let c = 0; c < row.length; c++) + { + const tile = row[c]; + + if(!tile || tile.blocked) continue; + + const [ x, y ] = tileToScreen(r, c); + const tileLeft = x - TILE_SIZE / 2; + const tileRight = x + TILE_SIZE / 2; + const tileTop = y; + const tileBottom = y + TILE_SIZE / 2; + + if(tileLeft < minX) minX = tileLeft; + if(tileRight > maxX) maxX = tileRight; + if(tileTop < minY) minY = tileTop; + if(tileBottom > maxY) maxY = tileBottom; + found = true; + } + } + + if(!found) return null; + + return { + x: minX - FIT_PADDING, + y: minY - FIT_PADDING, + w: (maxX - minX) + FIT_PADDING * 2, + h: (maxY - minY) + FIT_PADDING * 2 + }; +}; + +export const FloorplanCanvasSVG: FC = ({ state, dispatch }) => +{ + const svgRef = useRef(null); + const [ zoom, setZoom ] = useState(1); + const [ pan, setPan ] = useState<{ x: number; y: number }>({ x: 0, y: 0 }); + const [ isPanning, setIsPanning ] = useState(false); + const panStartRef = useRef<{ x: number; y: number; panX: number; panY: number } | null>(null); + // First-paint flag: once we've seen a non-empty room we + // auto-fit (zoom in/out until the room fills the canvas with + // a small margin) exactly once. Manual zoom/pan afterwards is + // preserved. + const centeredRef = useRef(false); + + const roomBounds = useMemo(() => computeRoomBounds(state), [ state.tiles ]); // eslint-disable-line react-hooks/exhaustive-deps + + // Pan a given zoom level so the room centre sits in the + // viewport centre. With zoom kept, the formula reduces to + // `roomCenter - VIEWBOX_center` because the (VIEWBOX - visible) + // / 2 base offset terms cancel. + const centerPanForRoom = useCallback((): { x: number; y: number } | null => + { + if(!roomBounds) return null; + return { + x: roomBounds.x + roomBounds.w / 2 - VIEWBOX_W / 2, + y: roomBounds.y + roomBounds.h / 2 - VIEWBOX_H / 2 + }; + }, [ roomBounds ]); + + // Fit-to-room: zooms IN/OUT until the whole room is visible + // (with a 5 % margin), then centres the pan. This is the + // default view — running on first paint and on every click of + // the %% / 'reset' label. + const fitToRoom = useCallback(() => + { + if(!roomBounds) return; + const zoomFitX = VIEWBOX_W / roomBounds.w; + const zoomFitY = VIEWBOX_H / roomBounds.h; + const targetZoom = clampZoom(Math.min(zoomFitX, zoomFitY) * 0.95); + const next = centerPanForRoom(); + if(!next) return; + setZoom(targetZoom); + setPan(next); + }, [ roomBounds, centerPanForRoom ]); + + // Auto-fit the FIRST time we see a non-empty room (typically + // right after the server-driven load). The literal 100 % zoom + // leaves too much empty space around small rooms, so the + // 'default view' is fit-to-room (~95 % of the smaller axis + // so tiles don't sit flush against the edge). The user's + // subsequent manual zoom / pan adjustments are preserved. + useEffect(() => + { + if(centeredRef.current) return; + if(!roomBounds) return; + centeredRef.current = true; + fitToRoom(); + }, [ roomBounds, fitToRoom ]); + + const visW = VIEWBOX_W / zoom; + const visH = VIEWBOX_H / zoom; + const baseX = (VIEWBOX_W - visW) / 2; + const baseY = (VIEWBOX_H - visH) / 2; + const viewX = baseX + pan.x; + const viewY = baseY + pan.y; + const viewBox = `${ viewX } ${ viewY } ${ visW } ${ visH }`; + + const projection = usePointerToTile(svgRef, { width: visW, height: visH, x: viewX, y: viewY }); + const tool = useTool(state, dispatch, projection); + + const rows = useMemo(() => state.tiles.map((row, r) => + { + const cells = row.map((tile, c) => + { + const isDoor = state.door.x === c && state.door.y === r && !tile.blocked; + const selected = state.selection.has(`${ r },${ c }`); + return ; + }); + return { cells }; + }), [ state.tiles, state.door.x, state.door.y, state.selection ]); + + const zoomIn = useCallback(() => setZoom(z => clampZoom(z + ZOOM_STEP)), []); + const zoomOut = useCallback(() => setZoom(z => clampZoom(z - ZOOM_STEP)), []); + // The %% label button restores the default view: fit-to-room + // (the same auto-fit that runs on first paint). Clicking it + // after manual zoom always gets you back to "room fills the + // canvas, room centred". + const resetView = useCallback(() => + { + fitToRoom(); + }, [ fitToRoom ]); + + const onWheel = useCallback((e: WheelEvent) => + { + if(!(e.ctrlKey || e.metaKey)) return; + e.preventDefault(); + setZoom(z => clampZoom(z + (e.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP))); + }, []); + + useEffect(() => + { + const onMove = (e: PointerEvent) => + { + const start = panStartRef.current; + if(!start) return; + const dx = e.clientX - start.x; + const dy = e.clientY - start.y; + const rect = svgRef.current?.getBoundingClientRect(); + if(!rect) return; + const scale = visW / rect.width; + setPan({ + x: start.panX - dx * scale, + y: start.panY - dy * scale + }); + }; + const onUp = () => + { + panStartRef.current = null; + setIsPanning(false); + }; + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', onUp); + return () => + { + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onUp); + }; + }, [ visW ]); + + const isPanGesture = (e: ReactPointerEvent): boolean => e.button === 1 || (e.button === 0 && e.shiftKey); + + return ( +
+ + { + if(isPanGesture(e)) + { + e.preventDefault(); + panStartRef.current = { x: e.clientX, y: e.clientY, panX: pan.x, panY: pan.y }; + setIsPanning(true); + return; + } + tool.onPointerDown(e); + } } + onPointerMove={ e => + { + if(panStartRef.current) return; + tool.onPointerMove(e); + } } + onPointerUp={ e => + { + if(panStartRef.current) return; + tool.onPointerUp(e); + } } + > + { rows } + +
+ + + + + +
+
+ ); +}; diff --git a/src/components/floorplan-editor/views/FloorplanCanvasView.tsx b/src/components/floorplan-editor/views/FloorplanCanvasView.tsx deleted file mode 100644 index 9db0903..0000000 --- a/src/components/floorplan-editor/views/FloorplanCanvasView.tsx +++ /dev/null @@ -1,179 +0,0 @@ -import { GetOccupiedTilesMessageComposer, GetRoomEntryTileMessageComposer, RoomEntryTileMessageEvent, RoomOccupiedTilesMessageEvent } from '@nitrots/nitro-renderer'; -import { FC, useEffect, useRef, useState } from 'react'; -import { FaPlus, FaMinus } from 'react-icons/fa'; -import { SendMessageComposer } from '../../../api'; -import { Base, Column, ColumnProps } from '../../../common'; -import { useMessageEvent } from '../../../hooks'; -import { useFloorplanEditorContext } from '../FloorplanEditorContext'; -import { FloorplanEditor } from '@nitrots/nitro-renderer'; - -interface FloorplanCanvasViewProps extends ColumnProps -{ -} - -export const FloorplanCanvasView: FC = props => -{ - const { gap = 1, children = null, ...rest } = props; - const [ occupiedTilesReceived, setOccupiedTilesReceived ] = useState(false); - const [ entryTileReceived, setEntryTileReceived ] = useState(false); - const [ zoomLevel, setZoomLevel ] = useState(1.0); - const { originalFloorplanSettings = null, setOriginalFloorplanSettings = null, setVisualizationSettings = null } = useFloorplanEditorContext(); - const elementRef = useRef(null); - const canvasWrapperRef = useRef(null); - - useMessageEvent(RoomOccupiedTilesMessageEvent, event => - { - const parser = event.getParser(); - - setOriginalFloorplanSettings(prevValue => - { - const newValue = { ...prevValue }; - - newValue.reservedTiles = parser.blockedTilesMap; - - FloorplanEditor.instance.setTilemap(newValue.tilemap, newValue.reservedTiles); - - return newValue; - }); - - setOccupiedTilesReceived(true); - - elementRef.current.scrollTo((FloorplanEditor.instance.renderer.canvas.width / 3), 0); - }); - - useMessageEvent(RoomEntryTileMessageEvent, event => - { - const parser = event.getParser(); - - setOriginalFloorplanSettings(prevValue => - { - const newValue = { ...prevValue }; - - newValue.entryPoint = [ parser.x, parser.y ]; - newValue.entryPointDir = parser.direction; - - return newValue; - }); - - setVisualizationSettings(prevValue => - { - const newValue = { ...prevValue }; - - newValue.entryPointDir = parser.direction; - - return newValue; - }); - - FloorplanEditor.instance.doorLocation = { x: parser.x, y: parser.y }; - - setEntryTileReceived(true); - }); - - const onPointerEvent = (event: PointerEvent) => - { - event.preventDefault(); - - switch(event.type) - { - case 'pointerout': - case 'pointerup': - FloorplanEditor.instance.onPointerRelease(event); - break; - case 'pointerdown': - FloorplanEditor.instance.onPointerDown(event); - break; - case 'pointermove': - FloorplanEditor.instance.onPointerMove(event); - break; - } - }; - - const zoomIn = () => setZoomLevel(prev => Math.min(prev + 0.25, 2.0)); - const zoomOut = () => setZoomLevel(prev => Math.max(prev - 0.25, 0.5)); - - useEffect(() => - { - return () => - { - FloorplanEditor.instance.clear(); - - setVisualizationSettings(prevValue => - { - return { - wallHeight: originalFloorplanSettings.wallHeight, - thicknessWall: originalFloorplanSettings.thicknessWall, - thicknessFloor: originalFloorplanSettings.thicknessFloor, - entryPointDir: prevValue.entryPointDir - }; - }); - }; - }, [ originalFloorplanSettings.thicknessFloor, originalFloorplanSettings.thicknessWall, originalFloorplanSettings.wallHeight, setVisualizationSettings ]); - - useEffect(() => - { - if(!entryTileReceived || !occupiedTilesReceived) return; - - FloorplanEditor.instance.renderTiles(); - }, [ entryTileReceived, occupiedTilesReceived ]); - - useEffect(() => - { - SendMessageComposer(new GetRoomEntryTileMessageComposer()); - SendMessageComposer(new GetOccupiedTilesMessageComposer()); - - const currentElement = elementRef.current; - - if(!currentElement) return; - - const wrapper = canvasWrapperRef.current; - - if(wrapper) wrapper.appendChild(FloorplanEditor.instance.renderer.canvas); - - currentElement.addEventListener('pointerup', onPointerEvent); - currentElement.addEventListener('pointerout', onPointerEvent); - currentElement.addEventListener('pointerdown', onPointerEvent); - currentElement.addEventListener('pointermove', onPointerEvent); - - return () => - { - if(currentElement) - { - currentElement.removeEventListener('pointerup', onPointerEvent); - currentElement.removeEventListener('pointerout', onPointerEvent); - currentElement.removeEventListener('pointerdown', onPointerEvent); - currentElement.removeEventListener('pointermove', onPointerEvent); - } - }; - }, []); - - return ( - - -
- -
- - -
- { children } - - ); -}; diff --git a/src/components/floorplan-editor/views/FloorplanHeightPicker.test.tsx b/src/components/floorplan-editor/views/FloorplanHeightPicker.test.tsx new file mode 100644 index 0000000..951435e --- /dev/null +++ b/src/components/floorplan-editor/views/FloorplanHeightPicker.test.tsx @@ -0,0 +1,28 @@ +import { describe, it, expect, vi } from 'vitest'; +import { fireEvent, render } from '@testing-library/react'; +import { FloorplanHeightPicker } from './FloorplanHeightPicker'; + +describe('FloorplanHeightPicker', () => +{ + it('renders 27 swatches', () => + { + const { container } = render( {} } />); + const swatches = container.querySelectorAll('[data-testid^="swatch-"]'); + expect(swatches).toHaveLength(27); + }); + + it('clicking a swatch fires onSelect with its height index', () => + { + const onSelect = vi.fn(); + const { container } = render(); + fireEvent.click(container.querySelector('[data-testid="swatch-5"]') as Element); + expect(onSelect).toHaveBeenCalledWith(5); + }); + + it('marks the selected swatch with data-selected', () => + { + const { container } = render( {} } />); + expect(container.querySelector('[data-testid="swatch-12"]')?.getAttribute('data-selected')).toBe('true'); + expect(container.querySelector('[data-testid="swatch-0"]')?.getAttribute('data-selected')).toBe('false'); + }); +}); diff --git a/src/components/floorplan-editor/views/FloorplanHeightPicker.tsx b/src/components/floorplan-editor/views/FloorplanHeightPicker.tsx new file mode 100644 index 0000000..58762cc --- /dev/null +++ b/src/components/floorplan-editor/views/FloorplanHeightPicker.tsx @@ -0,0 +1,54 @@ +import { FC } from 'react'; +import { HEIGHT_BRUSH_MAX, HEIGHT_BRUSH_MIN } from '../state/constants'; +import { tileFill } from '../state/selectors'; + +type Props = { + selectedH: number; + onSelect: (h: number) => void; +}; + +const SWATCH_W = 20; +const SWATCH_H = 14; + +export const FloorplanHeightPicker: FC = ({ selectedH, onSelect }) => +{ + const count = HEIGHT_BRUSH_MAX - HEIGHT_BRUSH_MIN + 1; + const totalH = count * SWATCH_H; + return ( +
+ { selectedH } + + { Array.from({ length: count }, (_, i) => + { + const h = HEIGHT_BRUSH_MAX - i; + const y = i * SWATCH_H; + const fill = tileFill({ h, blocked: false }); + const isSelected = selectedH === h; + return ( + onSelect(h) } + style={ { cursor: 'pointer' } } + /> + ); + }) } + +
+ ); +}; diff --git a/src/components/floorplan-editor/views/FloorplanHeightSelector.tsx b/src/components/floorplan-editor/views/FloorplanHeightSelector.tsx deleted file mode 100644 index 8163c98..0000000 --- a/src/components/floorplan-editor/views/FloorplanHeightSelector.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { FC } from 'react'; -import { COLORMAP, FloorAction, HEIGHT_SCHEME } from '@nitrots/nitro-renderer'; -import { FloorplanEditor } from '@nitrots/nitro-renderer'; -import { Column, Text } from '../../../common'; -import { useFloorplanEditorContext } from '../FloorplanEditorContext'; - -const colormap = COLORMAP as Record; - -export const FloorplanHeightSelector: FC<{}> = () => -{ - const { floorHeight, setFloorHeight, setFloorAction } = useFloorplanEditorContext(); - - const onSelectHeight = (height: number) => - { - setFloorHeight(height); - setFloorAction(FloorAction.SET); - - FloorplanEditor.instance.actionSettings.currentAction = FloorAction.SET; - FloorplanEditor.instance.actionSettings.currentHeight = height.toString(36); - }; - - const heights: number[] = []; - - for(let i = 26; i >= 0; i--) heights.push(i); - - return ( - - { floorHeight } -
- { heights.map(h => - { - const char = HEIGHT_SCHEME[h + 1]; - const color = colormap[char] || '101010'; - const isActive = (floorHeight === h); - - return ( -
onSelectHeight(h) } - title={ `${ h }` } - /> - ); - }) } -
- - ); -}; diff --git a/src/components/floorplan-editor/views/FloorplanImportExport.test.tsx b/src/components/floorplan-editor/views/FloorplanImportExport.test.tsx new file mode 100644 index 0000000..6b5c810 --- /dev/null +++ b/src/components/floorplan-editor/views/FloorplanImportExport.test.tsx @@ -0,0 +1,49 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { fireEvent, render, cleanup } from '@testing-library/react'; +import { FloorplanImportExport } from './FloorplanImportExport'; +import { initialState } from '../state/reducer'; + +describe('FloorplanImportExport', () => +{ + afterEach(() => cleanup()); + it('shows serialized tilemap of current state in textarea', () => + { + const state = { + ...initialState, + tiles: [ + [{ h: 0, blocked: false }, { h: 1, blocked: false }], + [{ h: 0, blocked: true }, { h: 2, blocked: false }] + ] + }; + render( {} } onClose={ () => {} } onSaveFromText={ () => {} } onRevertText={ () => '' } />); + const ta = document.querySelector('textarea') as HTMLTextAreaElement; + expect(ta).toBeTruthy(); + // Textarea normalizes \r to \n + expect(ta.value).toBe('01\nx2'); + }); + + it('clicking Load dispatches IMPORT_STRING with textarea content', () => + { + const dispatch = vi.fn(); + render( {} } onSaveFromText={ () => {} } onRevertText={ () => '' } />); + const ta = document.querySelector('textarea') as HTMLTextAreaElement; + expect(ta).toBeTruthy(); + // Textarea normalizes \r to \n + fireEvent.change(ta, { target: { value: 'xq\n00' } }); + const button = document.querySelector('[data-testid="import-load"]') as HTMLButtonElement; + expect(button).toBeTruthy(); + fireEvent.click(button); + expect(dispatch).toHaveBeenCalledWith({ type: 'IMPORT_STRING', raw: 'xq\n00', source: 'local' }); + }); + + it('clicking Save invokes onSaveFromText with textarea content', () => + { + const onSaveFromText = vi.fn(); + render( {} } onClose={ () => {} } onSaveFromText={ onSaveFromText } onRevertText={ () => '' } />); + const ta = document.querySelector('textarea') as HTMLTextAreaElement; + fireEvent.change(ta, { target: { value: '00\n01' } }); + const saveBtn = document.querySelector('[data-testid="import-save"]') as HTMLButtonElement; + fireEvent.click(saveBtn); + expect(onSaveFromText).toHaveBeenCalledWith('00\n01'); + }); +}); diff --git a/src/components/floorplan-editor/views/FloorplanImportExport.tsx b/src/components/floorplan-editor/views/FloorplanImportExport.tsx new file mode 100644 index 0000000..0063f10 --- /dev/null +++ b/src/components/floorplan-editor/views/FloorplanImportExport.tsx @@ -0,0 +1,53 @@ +import { Dispatch, FC, useState } from 'react'; +import { LocalizeText } from '../../../api'; +import { Button, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../common'; +import { FloorplanAction, FloorplanState } from '../state/types'; +import { serializeTilemap } from '../state/encoding'; + +type Props = { + state: FloorplanState; + dispatch: Dispatch; + onClose: () => void; + onSaveFromText: (raw: string) => void; + onRevertText: () => string; +}; + +export const FloorplanImportExport: FC = ({ state, dispatch, onClose, onSaveFromText, onRevertText }) => +{ + const [ raw, setRaw ] = useState(() => serializeTilemap(state.tiles)); + + const load = () => + { + dispatch({ type: 'IMPORT_STRING', raw, source: 'local' }); + onClose(); + }; + + const save = () => + { + onSaveFromText(raw); + onClose(); + }; + + const revert = () => + { + setRaw(onRevertText()); + }; + + return ( + + + +