Genesis
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
dist/
|
||||
node_modules/
|
||||
20
README.md
Normal file
20
README.md
Normal 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
29
eslint.config.js
Normal 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
28
index.html
Normal 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
2530
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
package.json
Normal file
30
package.json
Normal 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
BIN
public/kifferei.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
42
src/App.css
Normal file
42
src/App.css
Normal 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
350
src/App.jsx
Normal 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)} m²
|
||||
</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
56
src/RoomPolygon.jsx
Normal 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
83
src/Tile.jsx
Normal 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
265
src/index.css
Normal 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
10
src/main.jsx
Normal 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
95
src/utils/geometry.js
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Rotate vertices so the top edge (V3–V2) 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
30
vite.config.js
Normal 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';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user