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