feat: complete house plan maker application
Full-featured house/apartment floor plan editor with: - Turborepo monorepo (React/Vite client, Fastify/Prisma server, shared Zod schemas) - 2D room editor with walls, doors, windows, furniture, electrical elements - 3D room preview with Three.js (auto-hide nearest walls, bird's eye default) - Wall projection views with interactive drag (elevation, position) - Apartment floor plan view with room positioning - Copy/paste, alignment tools, measurement tool, annotations - Item-attached annotations with leader lines (visible on projections) - Door open direction (LEFT/RIGHT/INWARD/OUTWARD) with swing arc - Floor type textures (wood, tile, concrete, laminate, herringbone) - Wall color picker for 3D view - Furniture: bed, desk, wardrobe, sofa, table, chair, shelf, nightstand, dresser, bookcase, TV (with stand toggle), AC unit - Furniture elevation support (wall-mounted items) - Auto-save with dirty state tracking, batch save API - Rotation-aware collision detection (SAT/OBB) with 3D elevation check - Rotation-aware hit testing - i18n (English/Russian) with locale-aware number formatting - Dark mode with system preference detection - Undo/redo, keyboard shortcuts, scale bar - PDF/PNG/JSON export and JSON import - Focus trap modal, toast notifications, tooltips - Responsive layout with overlay palettes
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
import { memo, useMemo } from 'react';
|
||||
import { Layer, Text, Rect, Group, Line } from 'react-konva';
|
||||
import type { Point, Annotation, ElectricalItem, FurnitureItem } from '@house-plan-maker/shared';
|
||||
|
||||
interface AnnotationLayerProps {
|
||||
readonly annotations: readonly Annotation[];
|
||||
readonly electricalItems: readonly ElectricalItem[];
|
||||
readonly furnitureItems: readonly FurnitureItem[];
|
||||
readonly zoom: number;
|
||||
readonly panOffset: Point;
|
||||
readonly selectedIds: ReadonlySet<string>;
|
||||
readonly visible?: boolean;
|
||||
readonly onDragEnd?: (id: string, x: number, y: number) => void;
|
||||
readonly onDoubleClick?: (id: string) => void;
|
||||
readonly onSelect?: (id: string) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_FONT_SIZE = 14;
|
||||
const DEFAULT_COLOR = '#333333';
|
||||
const SELECTED_COLOR = '#4c6ef5';
|
||||
const SELECTION_PADDING = 4;
|
||||
|
||||
function toScreen(point: Point, zoom: number, panOffset: Point): { x: number; y: number } {
|
||||
return {
|
||||
x: point.x * zoom + panOffset.x,
|
||||
y: point.y * zoom + panOffset.y,
|
||||
};
|
||||
}
|
||||
|
||||
export const AnnotationLayer = memo(function AnnotationLayer({
|
||||
annotations,
|
||||
electricalItems,
|
||||
furnitureItems,
|
||||
zoom,
|
||||
panOffset,
|
||||
selectedIds,
|
||||
visible = true,
|
||||
onDragEnd,
|
||||
onDoubleClick,
|
||||
onSelect,
|
||||
}: AnnotationLayerProps) {
|
||||
// Build item position lookup for attached annotations
|
||||
const itemPositions = useMemo(() => {
|
||||
const map = new Map<string, { x: number; y: number }>();
|
||||
for (const item of electricalItems) {
|
||||
map.set(item.id, { x: item.x, y: item.y });
|
||||
}
|
||||
for (const item of furnitureItems) {
|
||||
map.set(item.id, { x: item.x + item.width / 2, y: item.y + item.depth / 2 });
|
||||
}
|
||||
return map;
|
||||
}, [electricalItems, furnitureItems]);
|
||||
|
||||
const renderedAnnotations = useMemo(() => {
|
||||
if (!visible) return [];
|
||||
return annotations;
|
||||
}, [annotations, visible]);
|
||||
|
||||
return (
|
||||
<Layer visible={visible}>
|
||||
{renderedAnnotations.map((annotation) => {
|
||||
// Resolve position: if attached, offset from parent item
|
||||
let worldX = annotation.x;
|
||||
let worldY = annotation.y;
|
||||
let parentScreen: { x: number; y: number } | null = null;
|
||||
|
||||
if (annotation.attachedToId) {
|
||||
const parentPos = itemPositions.get(annotation.attachedToId);
|
||||
if (parentPos) {
|
||||
worldX = parentPos.x + annotation.x;
|
||||
worldY = parentPos.y + annotation.y;
|
||||
parentScreen = toScreen(parentPos, zoom, panOffset);
|
||||
}
|
||||
}
|
||||
|
||||
const screen = toScreen({ x: worldX, y: worldY }, zoom, panOffset);
|
||||
const isSelected = selectedIds.has(annotation.id);
|
||||
const fontSize = annotation.fontSize ?? DEFAULT_FONT_SIZE;
|
||||
const color = isSelected ? SELECTED_COLOR : (annotation.color ?? DEFAULT_COLOR);
|
||||
|
||||
return (
|
||||
<Group key={annotation.id}>
|
||||
{/* Leader line from item to annotation */}
|
||||
{parentScreen && (
|
||||
<Line
|
||||
points={[parentScreen.x, parentScreen.y, screen.x, screen.y]}
|
||||
stroke={annotation.color ?? '#94a3b8'}
|
||||
strokeWidth={1}
|
||||
dash={[3, 3]}
|
||||
listening={false}
|
||||
/>
|
||||
)}
|
||||
<Group
|
||||
x={screen.x}
|
||||
y={screen.y}
|
||||
draggable
|
||||
onDragEnd={(e) => {
|
||||
const node = e.target;
|
||||
let newX = (node.x() - panOffset.x) / zoom;
|
||||
let newY = (node.y() - panOffset.y) / zoom;
|
||||
// For attached annotations, store as offset from parent
|
||||
if (annotation.attachedToId) {
|
||||
const parentPos = itemPositions.get(annotation.attachedToId);
|
||||
if (parentPos) {
|
||||
newX -= parentPos.x;
|
||||
newY -= parentPos.y;
|
||||
}
|
||||
}
|
||||
onDragEnd?.(annotation.id, newX, newY);
|
||||
}}
|
||||
onClick={() => onSelect?.(annotation.id)}
|
||||
onDblClick={() => onDoubleClick?.(annotation.id)}
|
||||
>
|
||||
{/* Background */}
|
||||
<Rect
|
||||
x={-2}
|
||||
y={-1}
|
||||
width={annotation.text.length * fontSize * 0.6 + 4}
|
||||
height={fontSize + 2}
|
||||
fill="rgba(255,255,255,0.85)"
|
||||
cornerRadius={2}
|
||||
listening={false}
|
||||
/>
|
||||
{isSelected && (
|
||||
<Rect
|
||||
x={-SELECTION_PADDING}
|
||||
y={-SELECTION_PADDING}
|
||||
width={annotation.text.length * fontSize * 0.6 + SELECTION_PADDING * 2}
|
||||
height={fontSize + SELECTION_PADDING * 2}
|
||||
stroke={SELECTED_COLOR}
|
||||
strokeWidth={1}
|
||||
dash={[4, 2]}
|
||||
fill="rgba(76, 110, 245, 0.05)"
|
||||
/>
|
||||
)}
|
||||
<Text
|
||||
text={annotation.text}
|
||||
fontSize={fontSize}
|
||||
fill={color}
|
||||
fontFamily="sans-serif"
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Layer>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user