This commit is contained in:
sebseb7
2026-06-07 23:05:46 +02:00
commit 53d9561de1
15 changed files with 3570 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
dist/
node_modules/

20
README.md Normal file
View File

@@ -0,0 +1,20 @@
# Raumplaner
Ein interaktiver 2D-Raumplaner, um Anbauflächen, Tische und Lichter optimal anzuordnen.
## Funktionen
- **Interaktives Layout**: Tische und Lampen per Klick hinzufügen und auf dem Raster (Canvas) frei per Drag & Drop verschieben.
- **Rotation**: Elemente können einfach gedreht werden, um den Platz optimal auszunutzen.
- **Bestandslimitierung**: Optional kann der Bestand der verfügbaren Tische und Lampen auf das tatsächliche Inventar limitiert werden.
- **Raumanpassung**: Wechsel zwischen der vollen Raumgröße (4,93m x 10,40m) und einer reduzierten Variante (4,93m x 4,50m).
- **Flächenberechnung**: Automatische Berechnung der gesamten Anbaufläche (in m²) basierend auf den platzierten Tischen.
- **Import & Export**: Layouts können als `.json`-Datei lokal gespeichert (exportiert) und später wieder geladen (importiert) werden.
## Technologien
- [React](https://react.dev/) - UI-Bibliothek
- [Vite](https://vitejs.dev/) - Build-Tool & Dev-Server
- [React Konva](https://konvajs.org/docs/react/index.html) - 2D Canvas Bibliothek für interaktive Grafiken
- [Lucide React](https://lucide.dev/) - Moderne Icons
- **Vanilla CSS** - Für ein schlankes, vollständig responsives UI

29
eslint.config.js Normal file
View File

@@ -0,0 +1,29 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
},
},
])

28
index.html Normal file
View File

@@ -0,0 +1,28 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/jpeg" href="/kifferei.jpg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Kifferei Raumplaner</title>
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:title" content="Kifferei Raumplaner" />
<meta property="og:description" content="Kifferei Raumplaner" />
<meta property="og:image" content="/kifferei.jpg" />
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:title" content="Kifferei Raumplaner" />
<meta property="twitter:description" content="Kifferei Raumplaner" />
<meta property="twitter:image" content="/kifferei.jpg" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

2530
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "roomplanner",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"konva": "^10.3.0",
"lucide-react": "^1.17.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-konva": "^19.2.4"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
"eslint": "^10.4.1",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"vite": "^8.0.16"
}
}

BIN
public/kifferei.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

42
src/App.css Normal file
View File

@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

350
src/App.jsx Normal file
View File

@@ -0,0 +1,350 @@
import React, { useState, useRef, useEffect } from 'react';
import { Stage, Layer, Group } from 'react-konva';
import { RoomPolygon } from './RoomPolygon';
import { Tile } from './Tile';
import { computeRoomVertices } from './utils/geometry';
import { Settings2, Plus, Trash2, Download, Upload } from 'lucide-react';
// Fixed real-world side lengths in meters (+ 180° CW: A←C, B←D, C←A, D←B)
const SCALE = 70; // 70 pixels per meter
function App() {
const [isReducedSize, setIsReducedSize] = useState(false);
const SIDE_A = isReducedSize ? 4.50 : 10.40;
const SIDE_B = 4.93;
const SIDE_C = isReducedSize ? 4.50 : 10.40;
const SIDE_D = 4.93;
const [alphaDeg, setAlphaDeg] = useState(90);
const [tiles, setTiles] = useState([]);
const [selectedId, setSelectedId] = useState(null);
const [dimensions, setDimensions] = useState({ width: 800, height: 600 });
const [limitInventory, setLimitInventory] = useState(false);
const count110x100 = tiles.filter(t => t.width === 1.1 && t.height === 1).length;
const count220x100 = tiles.filter(t => t.width === 2.2 && t.height === 1).length;
const count204x120 = tiles.filter(t => t.width === 2.04 && t.height === 1.2).length;
const count240x120 = tiles.filter(t => t.width === 2.4 && t.height === 1.2).length;
const countLights = tiles.filter(t => t.type === 'light').length;
const containerRef = useRef(null);
// Responsive canvas size
useEffect(() => {
const handleResize = () => {
if (containerRef.current) {
setDimensions({
width: containerRef.current.offsetWidth,
height: containerRef.current.offsetHeight,
});
}
};
handleResize();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
// Original geometry; rotate the whole group so the top edge is horizontal
const rawVertices = computeRoomVertices(SIDE_A, SIDE_B, SIDE_C, SIDE_D, alphaDeg);
const vertices = rawVertices;
const topEdgeAngle =
rawVertices &&
Math.atan2(
rawVertices[2].y - rawVertices[3].y,
rawVertices[2].x - rawVertices[3].x
);
const rotationDeg =
topEdgeAngle != null ? (-topEdgeAngle * 180) / Math.PI : 0;
const centroid =
rawVertices && rawVertices.length === 4
? {
x:
(rawVertices[0].x + rawVertices[1].x + rawVertices[2].x + rawVertices[3].x) /
4,
y:
(rawVertices[0].y + rawVertices[1].y + rawVertices[2].y + rawVertices[3].y) /
4,
}
: null;
const checkDeselect = (e) => {
// deselect when clicked on empty area (Stage) or the room itself
const clickedOnEmpty = e.target === e.target.getStage();
const clickedOnRoom = e.target.name() === 'room-polygon';
if (clickedOnEmpty || clickedOnRoom) {
setSelectedId(null);
}
};
const addTile = (width, height, label) => {
const isSquare = width === height;
// Assign colors based on dimensions
let color = "rgba(16, 185, 129, 0.6)"; // Default Green
let stroke = "#10b981";
if (width === 1.1 && height === 1) {
color = "rgba(139, 92, 246, 0.6)"; // Purple
stroke = "#8b5cf6";
} else if (width === 2.2 && height === 1) {
color = "rgba(14, 165, 233, 0.6)"; // Sky Blue
stroke = "#0ea5e9";
} else if (width === 2.04 && height === 1.2) {
color = "rgba(245, 158, 11, 0.6)"; // Amber
stroke = "#f59e0b";
} else if (width === 2.4 && height === 1.2) {
color = "rgba(20, 184, 166, 0.6)"; // Teal
stroke = "#14b8a6";
}
const newTile = {
id: `tile-${Date.now()}`,
label: label,
x: 1 * SCALE,
y: -1 * SCALE,
width: width,
height: height,
color: color,
stroke: stroke,
type: 'table'
};
setTiles([...tiles, newTile]);
setSelectedId(newTile.id);
};
const addLight = () => {
const newLight = {
id: `light-${Date.now()}`,
label: 'Licht 0.95x0.95',
x: 1 * SCALE,
y: -1 * SCALE,
width: 0.95,
height: 0.95,
color: "rgba(253, 224, 71, 0.6)", // Yellow
stroke: "#eab308",
type: 'light'
};
setTiles([...tiles, newLight]);
setSelectedId(newLight.id);
};
const exportTiles = () => {
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(tiles, null, 2));
const downloadAnchorNode = document.createElement('a');
downloadAnchorNode.setAttribute("href", dataStr);
downloadAnchorNode.setAttribute("download", "room-planner-tiles.json");
document.body.appendChild(downloadAnchorNode);
downloadAnchorNode.click();
downloadAnchorNode.remove();
};
const importTiles = (event) => {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const importedTiles = JSON.parse(e.target.result);
if (Array.isArray(importedTiles)) {
setTiles(importedTiles);
setSelectedId(null);
}
} catch (err) {
console.error("Failed to parse tiles JSON", err);
alert("Fehler beim Laden der Datei.");
}
};
reader.readAsText(file);
event.target.value = null;
};
const deleteSelected = () => {
if (selectedId) {
setTiles(tiles.filter(t => t.id !== selectedId));
setSelectedId(null);
}
};
const handleTileChange = (id, newAttrs) => {
setTiles(prevTiles => prevTiles.map(t => t.id === id ? { ...t, ...newAttrs } : t));
};
return (
<div className="layout">
{/* Sidebar Controls */}
<aside className="sidebar">
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', gap: '10px', marginBottom: '0.5rem', padding: '0 1rem' }}>
<input
id="sizeToggle"
type="checkbox"
checked={isReducedSize}
onChange={(e) => setIsReducedSize(e.target.checked)}
style={{ width: '1.2rem', height: '1.2rem', cursor: 'pointer' }}
/>
<label htmlFor="sizeToggle" style={{ cursor: 'pointer', fontSize: '0.9rem' }}>
Raumgröße auf 4.93 x 4.50m reduzieren
</label>
</div>
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', gap: '10px', marginBottom: '1rem', padding: '0 1rem' }}>
<input
id="limitToggle"
type="checkbox"
checked={limitInventory}
onChange={(e) => setLimitInventory(e.target.checked)}
style={{ width: '1.2rem', height: '1.2rem', cursor: 'pointer' }}
/>
<label htmlFor="limitToggle" style={{ cursor: 'pointer', fontSize: '0.9rem' }}>
Auf Bestand limitieren
</label>
</div>
<div className="actions">
<button
onClick={() => addTile(1.1, 1, '1.10x1.00')}
className="btn btn-primary"
style={{ backgroundColor: '#8b5cf6', boxShadow: '0 4px 6px -1px rgba(139, 92, 246, 0.4)' }}
disabled={limitInventory && count110x100 >= 4}
>
<Plus size={14} /> 1.10x1.00m hinzufügen {limitInventory && <span style={{ whiteSpace: 'nowrap' }}>({4 - count110x100} übrig)</span>}
</button>
<button
onClick={() => addTile(2.2, 1, '2.20x1.00')}
className="btn btn-primary"
style={{ backgroundColor: '#0ea5e9', boxShadow: '0 4px 6px -1px rgba(14, 165, 233, 0.4)' }}
disabled={limitInventory && count220x100 >= 6}
>
<Plus size={14} /> 2.20x1.00m hinzufügen {limitInventory && <span style={{ whiteSpace: 'nowrap' }}>({6 - count220x100} übrig)</span>}
</button>
<button
onClick={() => addTile(2.04, 1.2, '2.04x1.20')}
className="btn btn-primary"
style={{ backgroundColor: '#f59e0b', boxShadow: '0 4px 6px -1px rgba(245, 158, 11, 0.4)' }}
disabled={limitInventory && count204x120 >= 0}
>
<Plus size={14} /> 2.04x1.20m hinzufügen {limitInventory && <span style={{ whiteSpace: 'nowrap' }}>({0 - count204x120} übrig)</span>}
</button>
<button
onClick={() => addTile(2.4, 1.2, '2.40x1.20')}
className="btn btn-primary"
style={{ backgroundColor: '#14b8a6', boxShadow: '0 4px 6px -1px rgba(20, 184, 166, 0.4)' }}
disabled={limitInventory && count240x120 >= 3}
>
<Plus size={14} /> 2.40x1.20m hinzufügen {limitInventory && <span style={{ whiteSpace: 'nowrap' }}>({3 - count240x120} übrig)</span>}
</button>
<hr style={{ borderColor: 'var(--border)', margin: '1rem 0' }} />
<button
onClick={addLight}
className="btn btn-primary"
style={{ backgroundColor: '#eab308', boxShadow: '0 4px 6px -1px rgba(234, 179, 8, 0.4)' }}
disabled={limitInventory && countLights >= 22}
>
<Plus size={14} /> Licht (0.95x0.95m) {limitInventory && <span style={{ whiteSpace: 'nowrap' }}>({22 - countLights} übrig)</span>}
</button>
<hr style={{ borderColor: 'var(--border)', margin: '1rem 0' }} />
<button
onClick={deleteSelected}
className="btn btn-danger"
disabled={!selectedId}
>
<Trash2 size={14} /> Auswahl löschen
</button>
<hr style={{ borderColor: 'var(--border)', margin: '1rem 0' }} />
<button onClick={exportTiles} className="btn btn-primary" style={{ backgroundColor: '#4b5563', boxShadow: '0 4px 6px -1px rgba(75, 85, 99, 0.4)' }}>
<Download size={14} /> Exportieren...
</button>
<label className="btn btn-primary" style={{ backgroundColor: '#4b5563', boxShadow: '0 4px 6px -1px rgba(75, 85, 99, 0.4)', cursor: 'pointer', display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
<Upload size={14} /> Importieren...
<input type="file" accept=".json" onChange={importTiles} style={{ display: 'none' }} />
</label>
</div>
<div className="info-box">
<h3>Gesamte Anbaufläche</h3>
<p>
{tiles.filter(t => t.type !== 'light').reduce((sum, tile) => sum + (tile.width * tile.height), 0).toFixed(2)}
</p>
<span className="help-text">Aktuell {tiles.filter(t => t.type !== 'light').length} Tisch{tiles.filter(t => t.type !== 'light').length !== 1 ? 'e' : ''} platziert.</span>
</div>
</aside>
{/* Main Canvas Area */}
<main className="canvas-container" ref={containerRef}>
<Stage
width={dimensions.width}
height={dimensions.height}
draggable
onMouseDown={checkDeselect}
onTouchStart={checkDeselect}
>
<Layer>
{/* Center the room drawing in the stage.
V0 is bottom-left, so we move it down and right. */}
<Group
x={dimensions.width / 2}
y={dimensions.height / 2}
offsetX={centroid ? centroid.x * SCALE : 0}
offsetY={centroid ? centroid.y * SCALE : 0}
rotation={rotationDeg}
>
{vertices && <RoomPolygon vertices={vertices} scale={SCALE} />}
{/* Render tables first so they are below lights */}
{tiles.filter(t => t.type !== 'light').map((tile) => (
<Tile
key={tile.id}
id={tile.id}
initialX={tile.x}
initialY={tile.y}
scale={SCALE}
meterWidth={tile.width}
meterHeight={tile.height}
color={tile.color}
stroke={tile.stroke}
type={tile.type}
rotation={tile.rotation}
isSelected={tile.id === selectedId}
onSelect={() => setSelectedId(tile.id)}
onChange={(newAttrs) => handleTileChange(tile.id, newAttrs)}
/>
))}
{/* Render lights on top */}
{tiles.filter(t => t.type === 'light').map((tile) => (
<Tile
key={tile.id}
id={tile.id}
initialX={tile.x}
initialY={tile.y}
scale={SCALE}
meterWidth={tile.width}
meterHeight={tile.height}
color={tile.color}
stroke={tile.stroke}
type={tile.type}
rotation={tile.rotation}
isSelected={tile.id === selectedId}
onSelect={() => setSelectedId(tile.id)}
onChange={(newAttrs) => handleTileChange(tile.id, newAttrs)}
/>
))}
</Group>
</Layer>
</Stage>
</main>
</div>
);
}
export default App;

56
src/RoomPolygon.jsx Normal file
View File

@@ -0,0 +1,56 @@
import React from 'react';
import { Line, Group, Text } from 'react-konva';
export function RoomPolygon({ vertices, scale }) {
if (!vertices || vertices.length !== 4) return null;
// Flatten the array of vertices for Konva Line
// Multiply by scale to convert meters to pixels
const points = vertices.flatMap(v => [v.x * scale, v.y * scale]);
// Calculate center of each edge to place text
const edgeTexts = vertices.map((v, i) => {
const nextV = vertices[(i + 1) % vertices.length];
const midX = ((v.x + nextV.x) / 2) * scale;
const midY = ((v.y + nextV.y) / 2) * scale;
// Distance in meters (should map back to original a,b,c,d)
const dist = Math.sqrt(Math.pow(nextV.x - v.x, 2) + Math.pow(nextV.y - v.y, 2));
// Offset slightly outward from center
// We can just place it roughly at the midpoint
return { x: midX, y: midY, text: `${dist.toFixed(2)}m`, key: i };
});
return (
<Group>
<Line
name="room-polygon"
points={points}
closed={true}
stroke="#4f46e5" // vibrant indigo
strokeWidth={4}
fill="rgba(79, 70, 229, 0.1)"
lineJoin="round"
shadowColor="#4f46e5"
shadowBlur={15}
shadowOpacity={0.3}
/>
{edgeTexts.map(et => (
<Text
key={et.key}
x={et.x - 20}
y={et.y - 10}
text={et.text}
fontSize={14}
fill="#e2e8f0" // matching sleek dark mode text
fontFamily="system-ui"
shadowColor="black"
shadowBlur={2}
shadowOffsetX={1}
shadowOffsetY={1}
/>
))}
</Group>
);
}

83
src/Tile.jsx Normal file
View File

@@ -0,0 +1,83 @@
import React, { useRef, useState } from 'react';
import { Rect, Group, Transformer } from 'react-konva';
export function Tile({ id, initialX, initialY, scale, meterWidth = 1.6, meterHeight = 1.6, color = "rgba(16, 185, 129, 0.6)", stroke = "#10b981", isSelected, onSelect, type, rotation = 0, onChange }) {
const shapeRef = useRef();
const trRef = useRef();
const pixelWidth = meterWidth * scale;
const pixelHeight = meterHeight * scale;
// Sync up transformer when selected
React.useEffect(() => {
if (isSelected && trRef.current && shapeRef.current) {
trRef.current.nodes([shapeRef.current]);
trRef.current.getLayer().batchDraw();
}
}, [isSelected]);
const isLight = type === 'light';
return (
<Group>
<Rect
x={initialX}
y={initialY}
rotation={rotation}
width={pixelWidth}
height={pixelHeight}
fill={isLight ? null : color}
fillRadialGradientStartPoint={isLight ? { x: pixelWidth / 2, y: pixelHeight / 2 } : null}
fillRadialGradientStartRadius={isLight ? 0 : null}
fillRadialGradientEndPoint={isLight ? { x: pixelWidth / 2, y: pixelHeight / 2 } : null}
fillRadialGradientEndRadius={isLight ? Math.min(pixelWidth, pixelHeight) / 2 : null}
fillRadialGradientColorStops={isLight ? [0, 'rgba(253, 224, 71, 0.8)', 0.6, 'rgba(253, 224, 71, 0.5)', 1, 'rgba(253, 224, 71, 0)'] : null}
stroke={isLight ? null : stroke}
strokeWidth={isLight ? 0 : 2}
draggable
// Origin at center so rotation is around the center
offsetX={pixelWidth / 2}
offsetY={pixelHeight / 2}
ref={shapeRef}
onClick={onSelect}
onTap={onSelect}
shadowColor="rgba(0,0,0,0.5)"
shadowBlur={isSelected ? 10 : 5}
shadowOffset={{ x: 2, y: 2 }}
onDragStart={(e) => {
onSelect();
// visually pop out
e.target.moveToTop();
}}
onDragEnd={(e) => {
onChange && onChange({
x: e.target.x(),
y: e.target.y()
});
}}
onTransformEnd={(e) => {
const node = shapeRef.current;
node.scaleX(1);
node.scaleY(1);
onChange && onChange({
x: node.x(),
y: node.y(),
rotation: node.rotation()
});
}}
/>
{isSelected && (
<Transformer
ref={trRef}
resizeEnabled={false} // Only allow rotation
rotateEnabled={true}
centeredScaling={true}
rotationSnaps={[0, 45, 90, 135, 180, 225, 270, 315]}
anchorSize={8}
borderStroke="#facc15"
anchorStroke="#facc15"
anchorFill="#fff"
/>
)}
</Group>
);
}

265
src/index.css Normal file
View File

@@ -0,0 +1,265 @@
:root {
--bg-color: #0f172a;
/* slate-900 */
--panel-bg: #1e293b;
/* slate-800 */
--text-main: #f8fafc;
/* slate-50 */
--text-muted: #94a3b8;
/* slate-400 */
--accent: #4f46e5;
/* indigo-600 */
--accent-hover: #4338ca;
/* indigo-700 */
--danger: #ef4444;
/* red-500 */
--danger-hover: #dc2626;
/* red-600 */
--border: #334155;
/* slate-700 */
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html,
body,
#root {
height: 100%;
width: 100%;
overflow: hidden;
}
body {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
background-color: var(--bg-color);
color: var(--text-main);
}
.layout {
display: flex;
height: 100%;
width: 100%;
}
/* Sidebar styling */
.sidebar {
width: 192px;
height: 100%;
flex-shrink: 0;
background-color: var(--panel-bg);
border-right: 1px solid var(--border);
padding: 14px;
display: flex;
flex-direction: column;
gap: 8px;
box-shadow: 4px 0 15px rgba(0, 0, 0, 0.3);
z-index: 10;
overflow-y: auto;
}
.header {
display: flex;
align-items: center;
gap: 12px;
}
.header h1 {
font-size: 1.25rem;
font-weight: 600;
letter-spacing: -0.02em;
}
.icon-accent {
color: var(--accent);
}
.control-group {
display: flex;
flex-direction: column;
gap: 12px;
}
.label {
font-size: 0.95rem;
font-weight: 500;
display: flex;
justify-content: space-between;
}
.error {
color: var(--danger);
font-size: 0.8rem;
}
.help-text {
font-size: 0.8rem;
color: var(--text-muted);
line-height: 1.4;
}
/* Custom interactive slider */
.slider {
-webkit-appearance: none;
width: 100%;
height: 6px;
background: var(--border);
border-radius: 4px;
outline: none;
transition: all 0.2s;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--accent);
cursor: pointer;
box-shadow: 0 0 10px rgba(79, 70, 229, 0.5);
transition: transform 0.1s;
}
.slider::-webkit-slider-thumb:hover {
transform: scale(1.2);
}
/* Buttons */
.actions {
display: flex;
flex-direction: column;
gap: 12px;
}
.btn {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 6px;
padding: 6px 10px;
border: none;
border-radius: 6px;
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
color: white;
text-align: center;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background-color: var(--accent);
box-shadow: 0 4px 6px -1px rgba(79, 70, 229, 0.4);
}
.btn-primary:hover:not(:disabled) {
background-color: var(--accent-hover);
transform: translateY(-1px);
}
.btn-primary:active:not(:disabled) {
transform: translateY(1px);
}
.btn-danger {
background-color: var(--danger);
box-shadow: 0 4px 6px -1px rgba(239, 68, 68, 0.3);
}
.btn-danger:hover:not(:disabled) {
background-color: var(--danger-hover);
}
/* Info Box */
.info-box {
background-color: rgba(15, 23, 42, 0.4);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 16px;
}
.info-box h3 {
font-size: 0.9rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.info-box p {
font-size: 1.5rem;
font-weight: 700;
color: var(--accent);
}
/* Canvas area */
.canvas-container {
flex: 1;
position: relative;
background-image:
radial-gradient(circle at center, rgba(51, 65, 85, 0.3) 1px, transparent 1px);
background-size: 24px 24px;
}
@media (max-width: 768px) {
.layout {
flex-direction: column-reverse;
}
.sidebar {
width: 100%;
height: 45vh;
flex: none;
border-right: none;
border-top: 1px solid var(--border);
padding: 8px;
gap: 8px;
}
.canvas-container {
height: 55vh;
flex: none;
}
.actions {
gap: 6px;
}
.btn {
font-size: 0.7rem;
padding: 6px;
gap: 4px;
}
.header h1 {
font-size: 1rem;
}
.info-box {
margin-top: 8px;
padding: 8px;
gap: 4px;
}
.info-box p {
font-size: 1rem;
}
hr {
margin: 0.5rem 0 !important;
}
}

10
src/main.jsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)

95
src/utils/geometry.js Normal file
View File

@@ -0,0 +1,95 @@
/**
* Rotate vertices so the top edge (V3V2) is horizontal. Returns rotated vertices.
* Rotation is around the centroid. Angle in radians.
*/
export function rotateVerticesSoTopHorizontal(vertices) {
if (!vertices || vertices.length !== 4) return vertices;
const [v0, v1, v2, v3] = vertices;
const dx = v2.x - v3.x;
const dy = v2.y - v3.y;
const angle = Math.atan2(dy, dx);
const cos = Math.cos(-angle);
const sin = Math.sin(-angle);
const cx = (v0.x + v1.x + v2.x + v3.x) / 4;
const cy = (v0.y + v1.y + v2.y + v3.y) / 4;
const rotate = (v) => {
const px = v.x - cx;
const py = v.y - cy;
return {
x: px * cos - py * sin + cx,
y: px * sin + py * cos + cy,
};
};
return [rotate(v0), rotate(v1), rotate(v2), rotate(v3)];
}
export function computeRoomVertices(a, b, c, d, alphaDeg) {
// a: bottom, b: right, c: top, d: left
// alphaDeg: interior angle at bottom-left corner
const alpha = (alphaDeg * Math.PI) / 180;
// V0 is at the origin (bottom-left)
const v0 = { x: 0, y: 0 };
// V1 is at (a, 0)
const v1 = { x: a, y: 0 };
// V3 is at distance d from V0, at angle alpha
// Since canvas Y goes down, visually "up" is negative Y
const v3 = {
x: d * Math.cos(alpha),
y: -d * Math.sin(alpha)
};
// Calculate distance between V1 and V3
const D = Math.sqrt(Math.pow(v3.x - v1.x, 2) + Math.pow(v3.y - v1.y, 2));
// Test if a triangle can be formed with b, c, D
if (D > b + c || b > D + c || c > D + b) {
return null; // Invalid polygon
}
// Calculate intersection of two circles
// Circle 1: center V1, radius b
// Circle 2: center V3, radius c
// Using circle intersection formula:
const a_dist = (b * b - c * c + D * D) / (2 * D);
const h_sq = b * b - a_dist * a_dist;
const h = Math.sqrt(Math.max(0, h_sq));
const x_mid = v1.x + (a_dist / D) * (v3.x - v1.x);
const y_mid = v1.y + (a_dist / D) * (v3.y - v1.y);
const rx = -(v3.y - v1.y) / D;
const ry = (v3.x - v1.x) / D;
const p1 = { x: x_mid + h * rx, y: y_mid + h * ry };
const p2 = { x: x_mid - h * rx, y: y_mid - h * ry };
const crossProduct = (O, A, B) =>
(A.x - O.x) * (B.y - O.y) - (A.y - O.y) * (B.x - O.x);
const isConvex = (v2Test) => {
const cp1 = crossProduct(v0, v1, v2Test);
const cp2 = crossProduct(v1, v2Test, v3);
const cp3 = crossProduct(v2Test, v3, v0);
const cp0 = crossProduct(v3, v0, v1);
// In canvas Y down, convex polygon usually has same sign for all cross products of edges
const sign1 = Math.sign(cp1);
const sign2 = Math.sign(cp2);
const sign3 = Math.sign(cp3);
const sign0 = Math.sign(cp0);
return (sign1 === sign2 || sign1 === 0) && (sign2 === sign3 || sign2 === 0) && (sign3 === sign0 || sign3 === 0);
};
let v2 = p1;
// If p1 is not convex, or if we just want the "outer" one in Y (with negative y)
// we can use a simpler heuristic. Both might be valid but one is concave.
if (!isConvex(p1)) {
v2 = p2;
}
return [v0, v1, v2, v3];
}

30
vite.config.js Normal file
View File

@@ -0,0 +1,30 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
allowedHosts: true,
},
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules')) {
if (id.includes('konva')) {
return 'vendor-konva';
}
if (id.includes('react-dom')) {
return 'vendor-react-dom';
}
if (id.includes('react') || id.includes('scheduler')) {
return 'vendor-react';
}
return 'vendor';
}
}
}
}
}
})