Initial commit — Superfice.de website

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-30 16:11:39 +02:00
commit 75beb3607e
63 changed files with 11514 additions and 0 deletions

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
* text=auto

9
.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
node_modules/
dist/
.env
.DS_Store
*.log
api/config.php
.claude/
memory/
Thumbs.db

22
api/.htaccess Normal file
View File

@@ -0,0 +1,22 @@
# Block direct access to config file
<Files "config.php">
Order Allow,Deny
Deny from all
</Files>
# CORS headers for API endpoints
<IfModule mod_headers.c>
Header always set Access-Control-Allow-Origin "https://superfice.de"
Header always set Access-Control-Allow-Methods "GET, POST, OPTIONS"
Header always set Access-Control-Allow-Headers "Content-Type"
Header always set Access-Control-Max-Age "86400"
</IfModule>
# Handle OPTIONS preflight
RewriteEngine On
RewriteCond %{REQUEST_METHOD} OPTIONS
RewriteRule .* - [R=204,L]
# PHP settings
php_flag display_errors Off
php_flag log_errors On

133
api/contact.php Normal file
View File

@@ -0,0 +1,133 @@
<?php
/**
* Superfice Contact Form API
*
* POST /api/contact.php
* Content-Type: application/json
*
* Body:
* {
* "name": string (required),
* "contact": string (required, email or phone),
* "serviceType": string (optional),
* "message": string (optional)
* }
*
* Returns JSON: { "success": true }
* or { "success": false, "error": "<message>" }
*/
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/helpers.php';
header('Content-Type: application/json; charset=utf-8');
cors_headers();
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(204);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
json_error('Method not allowed', 405);
}
// -------------------------------------------------------
// Parse body
// -------------------------------------------------------
$raw = file_get_contents('php://input');
$body = json_decode($raw, true);
if (!is_array($body)) {
json_error('Ungültige Anfrage.');
}
// -------------------------------------------------------
// Rate limiting
// -------------------------------------------------------
rate_limit('contact_' . client_ip(), RATE_LIMIT_CONTACT, RATE_LIMIT_WINDOW);
// -------------------------------------------------------
// Validate
// -------------------------------------------------------
$name = trim($body['name'] ?? '');
$contact = trim($body['contact'] ?? '');
$serviceType = trim($body['serviceType'] ?? '');
$message = trim($body['message'] ?? '');
if ($name === '') {
json_error('Bitte geben Sie Ihren Namen an.');
}
if ($contact === '') {
json_error('Bitte geben Sie eine E-Mail-Adresse oder Telefonnummer an.');
}
if (mb_strlen($name) > 200 || mb_strlen($contact) > 300 || mb_strlen($message) > 5000) {
json_error('Ein Feld überschreitet die maximale Länge.');
}
// Sanitize
$name = htmlspecialchars($name, ENT_QUOTES, 'UTF-8');
$contact = htmlspecialchars($contact, ENT_QUOTES, 'UTF-8');
$serviceType = htmlspecialchars($serviceType, ENT_QUOTES, 'UTF-8');
$message = htmlspecialchars($message, ENT_QUOTES, 'UTF-8');
// -------------------------------------------------------
// Build e-mail
// -------------------------------------------------------
$subject = 'Neue Anfrage über superfice.de ' . ($serviceType ?: 'Allgemein');
$body_text = <<<TXT
Neue Anfrage über superfice.de
Name: {$name}
Kontakt: {$contact}
Aufmaß-Typ: {$serviceType}
Nachricht:
{$message}
---
IP: {$_SERVER['REMOTE_ADDR']}
Zeit: {$_SERVER['REQUEST_TIME']}
TXT;
$headers = "From: " . MAIL_FROM_NAME . " <" . MAIL_FROM . ">\r\n";
$headers .= "Reply-To: {$contact}\r\n";
$headers .= "MIME-Version: 1.0\r\n";
$headers .= "Content-Type: text/plain; charset=UTF-8\r\n";
$headers .= "X-Mailer: Superfice-Website/1.0\r\n";
// -------------------------------------------------------
// Send e-mail
// -------------------------------------------------------
if (USE_SMTP) {
send_smtp($subject, $body_text, $headers);
} else {
$sent = @mail(MAIL_TO, $subject, $body_text, $headers);
if (!$sent) {
error_log('Superfice contact mail() failed for: ' . $contact);
json_error('Beim Senden ist ein Fehler aufgetreten. Bitte schreiben Sie uns direkt an info@superfice.de');
}
}
// -------------------------------------------------------
// Optional webhook (Slack / CRM)
// -------------------------------------------------------
if (defined('WEBHOOK_URL') && WEBHOOK_URL !== '') {
$payload = json_encode([
'text' => "*Neue Superfice-Anfrage*\nName: {$name}\nKontakt: {$contact}\nTyp: {$serviceType}\nNachricht: {$message}",
]);
$wh_ctx = stream_context_create([
'http' => [
'method' => 'POST',
'header' => "Content-Type: application/json\r\n",
'content' => $payload,
'timeout' => 3,
],
]);
@file_get_contents(WEBHOOK_URL, false, $wh_ctx);
}
echo json_encode(['success' => true]);

77
api/distance.php Normal file
View File

@@ -0,0 +1,77 @@
<?php
/**
* Superfice Distance API
*
* GET /api/distance.php?plz=XXXXX
*
* Returns JSON: { "distance": <km>, "plz": "XXXXX" }
* or { "error": "<message>" }
*
* Uses OpenStreetMap Nominatim for geocoding (free, no API key needed).
* For production traffic, replace with Google Maps or a local PLZ dataset.
*/
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/helpers.php';
header('Content-Type: application/json; charset=utf-8');
cors_headers();
// -------------------------------------------------------
// Input validation
// -------------------------------------------------------
$plz = isset($_GET['plz']) ? preg_replace('/[^0-9]/', '', trim($_GET['plz'])) : '';
if (strlen($plz) !== 5) {
json_error('Ungültige PLZ. Bitte geben Sie eine 5-stellige Postleitzahl ein.');
}
// -------------------------------------------------------
// Rate limiting
// -------------------------------------------------------
rate_limit('dist_' . client_ip(), RATE_LIMIT_DISTANCE, RATE_LIMIT_WINDOW);
// -------------------------------------------------------
// Geocode PLZ via Nominatim
// -------------------------------------------------------
$url = sprintf(
'https://nominatim.openstreetmap.org/search?postalcode=%s&country=de&format=json&limit=1&addressdetails=0',
urlencode($plz)
);
$ctx = stream_context_create([
'http' => [
'header' => "User-Agent: SuperficeWebsite/1.0 (info@superfice.de)\r\n",
'timeout' => 5,
'ignore_errors' => true,
],
'ssl' => [
'verify_peer' => true,
'verify_peer_name'=> true,
],
]);
$raw = @file_get_contents($url, false, $ctx);
if ($raw === false) {
json_error('PLZ-Suche momentan nicht verfügbar. Bitte versuchen Sie es später erneut.');
}
$data = json_decode($raw, true);
if (empty($data) || !isset($data[0]['lat'], $data[0]['lon'])) {
json_error('PLZ nicht gefunden. Bitte prüfen Sie Ihre Eingabe.');
}
$lat = (float) $data[0]['lat'];
$lon = (float) $data[0]['lon'];
// -------------------------------------------------------
// Haversine distance to HQ
// -------------------------------------------------------
$distance = haversine(HQ_LAT, HQ_LON, $lat, $lon);
echo json_encode([
'distance' => $distance,
'plz' => $plz,
]);

112
api/helpers.php Normal file
View File

@@ -0,0 +1,112 @@
<?php
/**
* Superfice API Helpers
*
* Shared utility functions for API endpoints.
* Not directly accessible (no public entry point).
*/
// -------------------------------------------------------
// CORS
// -------------------------------------------------------
function cors_headers(): void {
$allowed = defined('SITE_ORIGIN') ? SITE_ORIGIN : '*';
header('Access-Control-Allow-Origin: ' . $allowed);
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
}
// -------------------------------------------------------
// JSON error response
// -------------------------------------------------------
function json_error(string $msg, int $code = 400): never {
http_response_code($code);
echo json_encode(['error' => $msg]);
exit;
}
// -------------------------------------------------------
// Client IP (respects proxy headers cautiously)
// -------------------------------------------------------
function client_ip(): string {
// Only trust X-Forwarded-For if behind a known proxy
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$ips = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']);
return trim($ips[0]);
}
return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
}
// -------------------------------------------------------
// File-based rate limiting
// -------------------------------------------------------
function rate_limit(string $key, int $maxRequests, int $window): void {
$dir = defined('RATE_LIMIT_DIR') ? RATE_LIMIT_DIR : sys_get_temp_dir() . '/sf_rl';
if (!is_dir($dir)) {
@mkdir($dir, 0700, true);
}
$file = $dir . '/' . hash('sha256', $key) . '.json';
$now = time();
$data = ['count' => 0, 'reset' => $now + $window];
if (file_exists($file)) {
$stored = json_decode(file_get_contents($file), true);
if (is_array($stored) && $stored['reset'] > $now) {
$data = $stored;
}
}
$data['count']++;
file_put_contents($file, json_encode($data), LOCK_EX);
if ($data['count'] > $maxRequests) {
http_response_code(429);
echo json_encode(['error' => 'Zu viele Anfragen. Bitte versuchen Sie es später erneut.']);
exit;
}
}
// -------------------------------------------------------
// Haversine distance (km)
// -------------------------------------------------------
function haversine(float $lat1, float $lon1, float $lat2, float $lon2): int {
$R = 6371;
$dLat = deg2rad($lat2 - $lat1);
$dLon = deg2rad($lon2 - $lon1);
$a = sin($dLat / 2) ** 2
+ cos(deg2rad($lat1)) * cos(deg2rad($lat2)) * sin($dLon / 2) ** 2;
$c = 2 * atan2(sqrt($a), sqrt(1 - $a));
return (int) round($R * $c);
}
// -------------------------------------------------------
// SMTP send (basic, without PHPMailer)
// For production, install PHPMailer via Composer and
// replace this function body accordingly.
// -------------------------------------------------------
function send_smtp(string $subject, string $body, string $headers): void {
// Placeholder integrate PHPMailer here:
//
// require __DIR__ . '/../vendor/autoload.php';
// $mail = new PHPMailer\PHPMailer\PHPMailer(true);
// $mail->isSMTP();
// $mail->Host = SMTP_HOST;
// $mail->SMTPAuth = true;
// $mail->Username = SMTP_USER;
// $mail->Password = SMTP_PASS;
// $mail->SMTPSecure = SMTP_SECURE;
// $mail->Port = SMTP_PORT;
// $mail->setFrom(MAIL_FROM, MAIL_FROM_NAME);
// $mail->addAddress(MAIL_TO);
// $mail->Subject = $subject;
// $mail->Body = $body;
// $mail->send();
// Fallback to mail() until PHPMailer is integrated
$sent = @mail(MAIL_TO, $subject, $body, $headers);
if (!$sent) {
error_log('Superfice SMTP fallback mail() failed');
json_error('Beim Senden ist ein Fehler aufgetreten. Bitte schreiben Sie uns direkt.');
}
}

19
index.html Normal file
View File

@@ -0,0 +1,19 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Millimetergenaue 3D-Aufmaße für Handwerk, Innenausbau und Architektur. Fertig aufbereitet für Ihre Planungssoftware." />
<meta name="robots" content="index, follow" />
<script id="Cookiebot" src="https://consent.cookiebot.com/uc.js" data-cbid="e49bbfc0-7402-4cda-9ae4-5bd1201ea9b5" data-blockingmode="auto" type="text/javascript"></script>
<meta property="og:title" content="Superfice Präzision beginnt beim Aufmaß" />
<meta property="og:description" content="Millimetergenaue 3D-Aufmaße für Handwerk, Innenausbau und Architektur." />
<meta property="og:type" content="website" />
<title>Superfice Präzision beginnt beim Aufmaß</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

1729
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

21
package.json Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "superfice",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@fontsource/inter": "^5.2.8",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.1",
"vite": "^5.4.0"
}
}

5
public/favicon.svg Normal file
View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="4" fill="#0B0B0B"/>
<rect x="6" y="10" width="3" height="12" fill="#8B3DB8"/>
<text x="12" y="22" font-family="Inter, sans-serif" font-size="11" font-weight="700" fill="#F0F0F0">SF</text>
</svg>

After

Width:  |  Height:  |  Size: 295 B

5
src/App.css Normal file
View File

@@ -0,0 +1,5 @@
main {
min-height: 100vh;
position: relative;
z-index: 1;
}

55
src/App.jsx Normal file
View File

@@ -0,0 +1,55 @@
import { useState, lazy, Suspense } from 'react'
import { Routes, Route } from 'react-router-dom'
import CustomCursor from './components/CustomCursor/CustomCursor'
import SpaceBackground from './components/SpaceBackground/SpaceBackground'
import Navigation from './components/Navigation/Navigation'
import Hero from './components/Hero/Hero'
import PainPoint from './components/PainPoint/PainPoint'
import Services from './components/Services/Services'
import WhatWeMeasure from './components/WhatWeMeasure/WhatWeMeasure'
import CostCalculator from './components/CostCalculator/CostCalculator'
import WhySuperfice from './components/WhySuperfice/WhySuperfice'
import References from './components/References/References'
import Contact from './components/Contact/Contact'
import Footer from './components/Footer/Footer'
import './App.css'
const Impressum = lazy(() => import('./pages/Impressum'))
const Datenschutz = lazy(() => import('./pages/Datenschutz'))
function HomePage() {
return (
<>
<section id="hero"><Hero /></section>
<section id="schmerzpunkt"><PainPoint /></section>
<section id="leistung"><Services /></section>
<section id="aufmass"><WhatWeMeasure /></section>
<section id="rechner"><CostCalculator /></section>
<section id="warum"><WhySuperfice /></section>
<section id="referenzen"><References /></section>
<section id="kontakt"><Contact /></section>
</>
)
}
export default function App() {
const [customCursor, setCustomCursor] = useState(true)
return (
<>
<CustomCursor enabled={customCursor} />
<SpaceBackground />
<Navigation customCursor={customCursor} onToggleCursor={() => setCustomCursor(v => !v)} />
<main>
<Suspense fallback={null}>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/impressum" element={<Impressum />} />
<Route path="/datenschutz" element={<Datenschutz />} />
</Routes>
</Suspense>
</main>
<Footer />
</>
)
}

View File

@@ -0,0 +1,162 @@
.bv-overlay {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(6, 6, 10, 0.82);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
animation: bv-fade-in 0.22s ease;
}
@keyframes bv-fade-in {
from { opacity: 0 }
to { opacity: 1 }
}
.bv-modal {
width: 100%;
max-width: 640px;
height: min(580px, 90vh);
background: rgba(14, 14, 22, 0.96);
border: 1px solid rgba(139, 61, 184, 0.40);
border-radius: 16px;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow:
0 0 0 1px rgba(139, 61, 184, 0.12),
0 24px 80px rgba(0, 0, 0, 0.65),
0 0 60px rgba(139, 61, 184, 0.12);
animation: bv-slide-up 0.28s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes bv-slide-up {
from { opacity: 0; transform: translateY(24px) scale(0.97) }
to { opacity: 1; transform: translateY(0) scale(1) }
}
.bv-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 1.5rem 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
flex-shrink: 0;
}
.bv-title-group {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.bv-label {
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--color-accent);
}
.bv-title {
font-size: 1.1rem;
font-weight: 700;
color: var(--color-text);
letter-spacing: -0.01em;
}
.bv-close {
width: 32px;
height: 32px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.10);
color: var(--color-text-muted);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background var(--transition), color var(--transition), border-color var(--transition);
flex-shrink: 0;
}
.bv-close svg { width: 14px; height: 14px; }
.bv-close:hover {
background: rgba(139, 61, 184, 0.22);
border-color: rgba(139, 61, 184, 0.50);
color: var(--color-text);
}
.bv-canvas-wrap {
flex: 1;
position: relative;
overflow: hidden;
}
.bv-canvas {
width: 100%;
height: 100%;
display: block;
cursor: grab;
opacity: 0;
transition: opacity 0.35s ease;
}
.bv-canvas--ready { opacity: 1; }
.bv-canvas:active { cursor: grabbing; }
.bv-loader {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
pointer-events: none;
}
.bv-loader-ring {
width: 40px;
height: 40px;
border-radius: 50%;
border: 2px solid rgba(139, 61, 184, 0.18);
border-top-color: var(--color-accent);
animation: bv-spin 0.85s linear infinite;
}
@keyframes bv-spin { to { transform: rotate(360deg); } }
.bv-loader-text {
font-size: 0.72rem;
letter-spacing: 0.10em;
text-transform: uppercase;
color: var(--color-text-faint);
animation: bv-pulse 1.6s ease-in-out infinite;
}
@keyframes bv-pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 0.9; }
}
.bv-footer {
display: flex;
align-items: center;
justify-content: center;
gap: 1.5rem;
padding: 0.75rem 1.5rem;
border-top: 1px solid rgba(255, 255, 255, 0.07);
flex-shrink: 0;
}
.bv-hint {
font-size: 0.72rem;
color: var(--color-text-faint);
letter-spacing: 0.03em;
}

View File

@@ -0,0 +1,559 @@
import { useEffect, useRef, useState } from 'react'
import './BathViewer.css'
// =================================================================
// BATHROOM GEOMETRY
// =================================================================
function buildBathroom() {
const V = [], E = []
// ring in x-z plane (horizontal)
function ring(cx, y, cz, rx, rz, n = 32) {
const b = V.length
for (let i = 0; i < n; i++) {
const a = (i / n) * Math.PI * 2
V.push([cx + rx * Math.cos(a), y, cz + rz * Math.sin(a)])
}
for (let i = 0; i < n; i++) E.push([b + i, b + (i + 1) % n])
return b
}
// ring in y-z plane (vertical, facing x-axis) — for washing machine door etc.
function ringYZ(x0, cy, cz, r, n = 20) {
const b = V.length
for (let i = 0; i < n; i++) {
const a = (i / n) * Math.PI * 2
V.push([x0, cy + r * Math.cos(a), cz + r * Math.sin(a)])
}
for (let i = 0; i < n; i++) E.push([b + i, b + (i + 1) % n])
return b
}
function connectRings(b1, b2, n) {
for (let i = 0; i < n; i++) E.push([b1 + i, b2 + i])
}
function box(x1, y1, z1, x2, y2, z2) {
const b = V.length
V.push(
[x1,y1,z1],[x2,y1,z1],[x2,y1,z2],[x1,y1,z2],
[x1,y2,z1],[x2,y2,z1],[x2,y2,z2],[x1,y2,z2],
)
E.push(
[b,b+1],[b+1,b+2],[b+2,b+3],[b+3,b],
[b+4,b+5],[b+5,b+6],[b+6,b+7],[b+7,b+4],
[b,b+4],[b+1,b+5],[b+2,b+6],[b+3,b+7],
)
}
function line(x1,y1,z1, x2,y2,z2) {
const b = V.length
V.push([x1,y1,z1],[x2,y2,z2])
E.push([b,b+1])
}
// cylinder (2 rings + verticals)
function cylinder(cx, y1, cz, rx, rz, y2, n = 20) {
const b1 = ring(cx, y1, cz, rx, rz, n)
const b2 = ring(cx, y2, cz, rx, rz, n)
connectRings(b1, b2, n)
}
// ── ROOM ──────────────────────────────────────────────────────
const RX = 1.55, RZ = 2.20, FL = -0.50, CL = 1.30
const rc = V.length
V.push(
[-RX,FL,-RZ],[RX,FL,-RZ],[RX,FL, RZ],[-RX,FL, RZ],
[-RX,CL,-RZ],[RX,CL,-RZ],[RX,CL, RZ],[-RX,CL, RZ],
)
E.push(
[rc,rc+1],[rc+1,rc+2],[rc+2,rc+3],[rc+3,rc],
[rc+4,rc+5],[rc+5,rc+6],[rc+6,rc+7],[rc+7,rc+4],
[rc,rc+4],[rc+1,rc+5],[rc+2,rc+6],[rc+3,rc+7],
)
// Door frame (front wall z=-RZ)
{
const d = V.length
V.push([0.28,FL,-RZ],[0.28,FL+1.72,-RZ],[1.04,FL,-RZ],[1.04,FL+1.72,-RZ])
E.push([d,d+1],[d+2,d+3],[d+1,d+3])
}
// Door panel (open ~40°, hinge at x=1.04)
{
const hw = 0.76, hh = 1.72, ang = 0.70
const fx = 1.04 - hw * Math.cos(ang)
const fz = -RZ + hw * Math.sin(ang)
const d = V.length
V.push(
[1.04, FL, -RZ], [1.04, FL+hh, -RZ],
[fx, FL, fz], [fx, FL+hh, fz],
)
E.push([d,d+1],[d+2,d+3],[d,d+2],[d+1,d+3])
// Horizontal mid-panel line
line(1.04, FL+0.86, -RZ, fx, FL+0.86, fz)
// Lever handle
const hpx = 1.04 - 0.58*hw*Math.cos(ang)
const hpz = -RZ + 0.58*hw*Math.sin(ang)
box(hpx-0.025, FL+0.86, hpz-0.025, hpx+0.025, FL+0.96, hpz+0.025)
line(hpx, FL+0.96, hpz, hpx+0.06, FL+0.90, hpz-0.04)
// Hinges (3, on hinge-side frame)
for (const hy of [FL+0.08, FL+0.86, FL+1.60]) {
box(1.02, hy, -RZ, 1.06, hy+0.06, -RZ+0.04)
}
}
// Window (right wall x=RX) with cross bar + sill
{
const w = V.length
V.push([RX,0.28,-0.44],[RX,0.28,0.54],[RX,0.94,-0.44],[RX,0.94,0.54])
E.push([w,w+1],[w+2,w+3],[w,w+2],[w+1,w+3])
V.push([RX,0.61,-0.44],[RX,0.61,0.54])
E.push([w+4,w+5])
box(RX-0.06, 0.24, -0.48, RX, 0.28, 0.58)
}
// Ceiling light
ring(0.10, CL, -0.20, 0.20, 0.20, 20)
ring(0.10, CL, -0.20, 0.09, 0.09, 14)
line(0.10, CL, -0.20, 0.10, CL-0.20, -0.20)
// ── BATHTUB (back wall, long axis = x) ────────────────────────
const TCX = -0.20, TCZ = 1.80, TN = 32
const toR = [
{ y: 0.16, rx: 1.00, rz: 0.37 },
{ y: 0.07, rx: 1.04, rz: 0.40 },
{ y: -0.06, rx: 0.99, rz: 0.37 },
{ y: -0.20, rx: 0.92, rz: 0.31 },
{ y: -0.32, rx: 0.86, rz: 0.29 },
{ y: -0.38, rx: 0.82, rz: 0.32 },
]
const tiR = [
{ y: 0.16, rx: 0.84, rz: 0.26 },
{ y: -0.06, rx: 0.81, rz: 0.23 },
{ y: -0.28, rx: 0.77, rz: 0.20 },
]
const toRings = toR.map(({ y, rx, rz }) => ring(TCX, y, TCZ, rx, rz, TN))
for (let r = 0; r < toRings.length - 1; r++) connectRings(toRings[r], toRings[r+1], TN)
const tiRings = tiR.map(({ y, rx, rz }) => ring(TCX, y, TCZ, rx, rz, TN))
for (let r = 0; r < tiRings.length - 1; r++) connectRings(tiRings[r], tiRings[r+1], TN)
for (let i = 0; i < TN; i++) E.push([toRings[0]+i, tiRings[0]+i])
// Faucet
box(TCX+0.82, 0.16, TCZ-0.09, TCX+0.96, 0.36, TCZ+0.09)
box(TCX+0.66, 0.16, TCZ-0.20, TCX+0.74, 0.27, TCZ-0.11)
box(TCX+0.66, 0.16, TCZ+0.11, TCX+0.74, 0.27, TCZ+0.20)
line(TCX+0.89, 0.36, TCZ, TCX+0.89, 0.44, TCZ-0.14)
// Drain + soap dish
ring(TCX-0.28, -0.38, TCZ, 0.06, 0.06, 12)
box(TCX+0.28, 0.16, TCZ-0.38, TCX+0.50, 0.20, TCZ-0.32)
// Claw feet — (0.58/0.82)²+(0.21/0.32)²=0.501+0.431=0.932 < 1 ✓
for (const [lx, lz] of [
[TCX-0.58, TCZ-0.21],[TCX-0.58, TCZ+0.21],
[TCX+0.58, TCZ-0.21],[TCX+0.58, TCZ+0.21],
]) {
cylinder(lx, FL, lz, 0.055, 0.055, -0.38, 14)
ring(lx, -0.38, lz, 0.09, 0.09, 14)
ring(lx, FL, lz, 0.09, 0.09, 14)
}
// Shower pipe + head + curtain rail
{
const shx = TCX - 0.80, shz = TCZ
cylinder(shx, 0.16, shz, 0.025, 0.025, 1.10, 8)
ring(shx, 1.10, shz, 0.15, 0.15, 16)
box(-RX, 1.05, shz-0.025, shx, 1.10, shz+0.025)
ring(TCX, CL-0.06, TCZ, 1.05, 0.42, 28)
}
// ── TOILET — wall-hung modern (left wall, between sink and tub)
const TOLX = -1.27, TOLZ = 0.20, TolN = 28
// Bowl: tapers at bottom, widens at rim (wall-hung, no floor contact)
const tolR = [
{ y: FL+0.24, rx: 0.21, rz: 0.17 },
{ y: FL+0.31, rx: 0.25, rz: 0.20 },
{ y: FL+0.39, rx: 0.28, rz: 0.22 },
{ y: FL+0.44, rx: 0.28, rz: 0.22 },
]
const tolRings = tolR.map(({ y, rx, rz }) => ring(TOLX, y, TOLZ, rx, rz, TolN))
for (let r = 0; r < tolRings.length - 1; r++) connectRings(tolRings[r], tolRings[r+1], TolN)
// Seat (two rings = thickness of seat edge)
ring(TOLX, FL+0.455, TOLZ, 0.29, 0.23, TolN)
ring(TOLX, FL+0.455, TOLZ, 0.24, 0.18, TolN)
// Lid (slightly above seat)
ring(TOLX, FL+0.475, TOLZ, 0.28, 0.22, TolN)
ring(TOLX, FL+0.510, TOLZ, 0.28, 0.22, TolN)
// Wall-hung support brackets
line(-RX, FL+0.28, TOLZ-0.12, TOLX+0.27, FL+0.28, TOLZ-0.12)
line(-RX, FL+0.28, TOLZ+0.12, TOLX+0.27, FL+0.28, TOLZ+0.12)
// In-wall cistern flush panel on wall
box(-RX, FL+0.64, TOLZ-0.16, -RX+0.05, FL+0.92, TOLZ+0.16)
// Two flush buttons
box(-RX+0.01, FL+0.74, TOLZ-0.10, -RX+0.05, FL+0.86, TOLZ-0.02)
box(-RX+0.01, FL+0.74, TOLZ+0.02, -RX+0.05, FL+0.86, TOLZ+0.10)
// Toilet paper holder (left wall)
{
const ph = V.length
V.push([-RX, 0.08, TOLZ+0.48],[-RX+0.16, 0.08, TOLZ+0.48])
E.push([ph, ph+1])
cylinder(-RX+0.16, 0.02, TOLZ+0.48, 0.0, 0.09, 0.12, 12)
}
// Trash can — against left wall, between toilet and sink
cylinder(-1.38, FL, -0.20, 0.10, 0.10, FL+0.30, 16)
ring(-1.38, FL+0.30, -0.20, 0.12, 0.12, 16)
// ── WASHING MACHINE (right wall, under window z≈-0.44..0.54) ──
box(RX-0.58, FL, -0.30, RX, FL+0.64, 0.40)
// Porthole door: gasket ring + glass ring + drum
ringYZ(RX-0.58, FL+0.32, 0.05, 0.26, 22) // outer gasket
ringYZ(RX-0.58, FL+0.32, 0.05, 0.23, 22) // glass ring
ringYZ(RX-0.58, FL+0.32, 0.05, 0.10, 12) // drum ring
// Drum paddles (4 lines from center to drum)
for (let i = 0; i < 4; i++) {
const a = (i / 4) * Math.PI * 2
line(RX-0.58, FL+0.32, 0.05,
RX-0.58, FL+0.32 + 0.10*Math.cos(a), 0.05 + 0.10*Math.sin(a))
}
// Porthole handle
box(RX-0.60, FL+0.28, 0.26, RX-0.54, FL+0.36, 0.30)
// Control panel + buttons
box(RX-0.58, FL+0.64, -0.24, RX, FL+0.70, 0.40)
ringYZ(RX-0.58, FL+0.67, -0.10, 0.05, 10) // main dial
// Button row
for (let i = 0; i < 4; i++) {
const bz = 0.10 + i * 0.06
box(RX-0.58, FL+0.655, bz, RX-0.54, FL+0.685, bz+0.04)
}
// ── TOWEL RADIATOR (right wall, mid) ─────────────────────────
{
const RY1 = -0.20, RY2 = 0.88
const RZ1 = 0.65, RZ2 = 1.25
const RXf = RX - 0.10
line(RXf, RY1, RZ1, RXf, RY2, RZ1)
line(RXf, RY1, RZ2, RXf, RY2, RZ2)
for (let i = 0; i <= 7; i++) {
const y = RY1 + (RY2 - RY1) * i / 7
line(RXf, y, RZ1, RXf, y, RZ2)
}
line(RX, RY1+0.12, RZ1, RXf, RY1+0.12, RZ1)
line(RX, RY1+0.12, RZ2, RXf, RY1+0.12, RZ2)
line(RX, RY2-0.12, RZ1, RXf, RY2-0.12, RZ1)
line(RX, RY2-0.12, RZ2, RXf, RY2-0.12, RZ2)
}
// ── SINK (left wall, front) ───────────────────────────────────
const SNX = -1.55, SNZ = -1.46
// sink bowl as oval rings
const sn0 = ring(SNX+0.19, 0.28, SNZ, 0.20, 0.25, 24)
const sn1 = ring(SNX+0.17, 0.08, SNZ, 0.17, 0.21, 24)
connectRings(sn0, sn1, 24)
// rim ledge
ring(SNX+0.20, 0.30, SNZ, 0.24, 0.28, 24)
// faucet body
cylinder(SNX+0.19, 0.30, SNZ, 0.025, 0.025, 0.50, 8)
ring(SNX+0.19, 0.50, SNZ, 0.06, 0.06, 10)
// handles
box(SNX+0.06, 0.38, SNZ-0.14, SNX+0.14, 0.42, SNZ-0.08)
box(SNX+0.06, 0.38, SNZ+0.08, SNX+0.14, 0.42, SNZ+0.14)
// Supply pipes (from wall → into room → up to faucet)
line(-RX, 0.10, SNZ-0.12, SNX+0.12, 0.10, SNZ-0.12) // cold horizontal
line(SNX+0.12, 0.10, SNZ-0.12, SNX+0.08, 0.38, SNZ-0.12) // cold up
ring(SNX+0.12, 0.19, SNZ-0.12, 0.0, 0.032, 8) // cold shutoff valve
line(-RX, 0.10, SNZ+0.12, SNX+0.12, 0.10, SNZ+0.12) // hot horizontal
line(SNX+0.12, 0.10, SNZ+0.12, SNX+0.08, 0.38, SNZ+0.12) // hot up
ring(SNX+0.12, 0.19, SNZ+0.12, 0.0, 0.032, 8) // hot shutoff valve
// P-trap drain
const DX = SNX+0.19
line(DX, 0.06, SNZ, DX, -0.14, SNZ) // vertical drop
line(DX, -0.14, SNZ, DX, -0.14, SNZ+0.14) // trap horizontal →
line(DX, -0.14, SNZ+0.14, DX, -0.08, SNZ+0.14) // trap back up (U)
line(DX, -0.08, SNZ+0.14, DX, -0.08, SNZ+0.28) // exit horizontal
line(DX, -0.08, SNZ+0.28, DX, -0.22, SNZ+0.28) // drop to floor
// Large mirror above sink (spans wide)
{
const m = V.length
V.push(
[-RX, 0.44, SNZ-0.52],[-RX, 0.44, SNZ+0.52],
[-RX, 1.18, SNZ-0.52],[-RX, 1.18, SNZ+0.52],
)
E.push([m,m+1],[m+2,m+3],[m,m+2],[m+1,m+3])
// mirror shelf below
box(-RX, 0.38, SNZ-0.54, -RX+0.06, 0.44, SNZ+0.54)
}
// Towel ring next to sink
{
const b = V.length
V.push([-RX, 0.46, SNZ+0.68],[-RX+0.04, 0.46, SNZ+0.68])
E.push([b, b+1])
ring(-RX+0.04, 0.46, SNZ+0.68, 0.0, 0.11, 14)
}
// ── CARPET (floor oval, room center-front) ────────────────────
ring(0.20, FL+0.01, -0.55, 0.85, 0.62, 40)
ring(0.20, FL+0.01, -0.55, 0.72, 0.50, 40)
return { verts: V, edges: E }
}
const { verts, edges } = buildBathroom()
// =================================================================
// ENGINE — do not touch below this line
// =================================================================
export default function BathViewer({ onClose }) {
const canvasRef = useRef(null)
const [ready, setReady] = useState(false)
const stateRef = useRef({
rotX: 0.30, rotY: -0.55,
dragging: false, lastX: 0, lastY: 0,
autoRotate: true, idleTimer: null,
zoom: 0.78, rafId: null, lastTs: 0,
})
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
const state = stateRef.current
const resize = () => {
const dpr = Math.min(window.devicePixelRatio || 1, 2)
canvas.width = canvas.offsetWidth * dpr
canvas.height = canvas.offsetHeight * dpr
}
resize()
const ro = new ResizeObserver(resize)
ro.observe(canvas)
function project(v) {
let [x, y, z] = v
const { rotX, rotY, zoom } = state
const dpr = Math.min(window.devicePixelRatio || 1, 2)
const W = canvas.width / dpr
const H = canvas.height / dpr
const cy_ = Math.cos(rotY), sy_ = Math.sin(rotY)
const x1 = x * cy_ - z * sy_
const z1 = x * sy_ + z * cy_
const cx_ = Math.cos(rotX), sx_ = Math.sin(rotX)
const y2 = y * cx_ - z1 * sx_
const z2 = y * sx_ + z1 * cx_
const fov = Math.min(W, H) * 1.75
const scale = Math.min(W, H) * 0.095 * zoom
const d = fov + z2
return {
x: (x1 / d) * fov * scale + W / 2,
y: (-y2 / d) * fov * scale + H / 2,
z: z2,
}
}
function render() {
const dpr = Math.min(window.devicePixelRatio || 1, 2)
const W = canvas.width / dpr
const H = canvas.height / dpr
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, W, H)
if (verts.length === 0) return
const proj = verts.map(v => project(v))
let zMin = Infinity, zMax = -Infinity
for (const p of proj) {
if (p.z < zMin) zMin = p.z
if (p.z > zMax) zMax = p.z
}
const zRange = zMax - zMin || 1
const sorted = edges
.map(([a, b]) => ({ a, b, depth: (proj[a].z + proj[b].z) * 0.5 }))
.sort((e1, e2) => e2.depth - e1.depth)
for (const { a, b, depth } of sorted) {
const pa = proj[a]
const pb = proj[b]
const t = (depth - zMin) / zRange
const da = Math.max(0.10, Math.min(1.0, 1.05 - t * 0.88))
ctx.beginPath()
ctx.moveTo(pa.x, pa.y)
ctx.lineTo(pb.x, pb.y)
ctx.strokeStyle = `rgba(190,110,255,${da})`
ctx.lineWidth = 1.3
ctx.stroke()
}
for (const p of proj) {
const t = (p.z - zMin) / zRange
const da = Math.max(0.08, 0.45 - t * 0.35)
ctx.beginPath()
ctx.arc(p.x, p.y, 1.4, 0, Math.PI * 2)
ctx.fillStyle = `rgba(220,170,255,${da})`
ctx.fill()
}
}
let firstFrame = true
function loop(ts) {
state.rafId = requestAnimationFrame(loop)
if (ts - state.lastTs < 32) return
state.lastTs = ts
if (state.autoRotate && !state.dragging) state.rotY += 0.006
render()
if (firstFrame) { firstFrame = false; setReady(true) }
}
state.rafId = requestAnimationFrame(loop)
const stopAutoRotate = () => {
state.autoRotate = false
clearTimeout(state.idleTimer)
state.idleTimer = setTimeout(() => { state.autoRotate = true }, 2200)
}
const onMouseDown = (e) => {
state.dragging = true
state.lastX = e.clientX
state.lastY = e.clientY
stopAutoRotate()
}
const onMouseMove = (e) => {
if (!state.dragging) return
state.rotY += (e.clientX - state.lastX) * 0.008
state.rotX += (e.clientY - state.lastY) * 0.008
state.rotX = Math.max(-0.75, Math.min(0.75, state.rotX))
state.lastX = e.clientX
state.lastY = e.clientY
}
const onMouseUp = () => { state.dragging = false }
const onWheel = (e) => {
e.preventDefault()
state.zoom *= e.deltaY > 0 ? 0.93 : 1.07
state.zoom = Math.max(0.35, Math.min(3.0, state.zoom))
stopAutoRotate()
}
let lastTX = 0, lastTY = 0, lastPinchDist = 0
const onTouchStart = (e) => {
stopAutoRotate()
state.dragging = true
if (e.touches.length === 1) {
lastTX = e.touches[0].clientX
lastTY = e.touches[0].clientY
} else if (e.touches.length === 2) {
lastPinchDist = Math.hypot(
e.touches[0].clientX - e.touches[1].clientX,
e.touches[0].clientY - e.touches[1].clientY,
)
}
}
const onTouchMove = (e) => {
if (!state.dragging) return
if (e.touches.length === 1) {
state.rotY += (e.touches[0].clientX - lastTX) * 0.008
state.rotX += (e.touches[0].clientY - lastTY) * 0.008
state.rotX = Math.max(-0.75, Math.min(0.75, state.rotX))
lastTX = e.touches[0].clientX
lastTY = e.touches[0].clientY
} else if (e.touches.length === 2) {
const dist = Math.hypot(
e.touches[0].clientX - e.touches[1].clientX,
e.touches[0].clientY - e.touches[1].clientY,
)
if (lastPinchDist) state.zoom *= dist / lastPinchDist
state.zoom = Math.max(0.35, Math.min(3.0, state.zoom))
lastPinchDist = dist
}
}
const onTouchEnd = () => { state.dragging = false; lastPinchDist = 0 }
canvas.addEventListener('mousedown', onMouseDown)
window.addEventListener('mousemove', onMouseMove)
window.addEventListener('mouseup', onMouseUp)
canvas.addEventListener('wheel', onWheel, { passive: false })
canvas.addEventListener('touchstart', onTouchStart, { passive: true })
canvas.addEventListener('touchmove', onTouchMove, { passive: true })
canvas.addEventListener('touchend', onTouchEnd)
const onKeyDown = (e) => { if (e.key === 'Escape') onClose() }
window.addEventListener('keydown', onKeyDown)
return () => {
cancelAnimationFrame(state.rafId)
clearTimeout(state.idleTimer)
ro.disconnect()
setReady(false)
canvas.removeEventListener('mousedown', onMouseDown)
window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('mouseup', onMouseUp)
canvas.removeEventListener('wheel', onWheel)
canvas.removeEventListener('touchstart', onTouchStart)
canvas.removeEventListener('touchmove', onTouchMove)
canvas.removeEventListener('touchend', onTouchEnd)
window.removeEventListener('keydown', onKeyDown)
}
}, [onClose])
return (
<div className="bv-overlay" onClick={onClose}>
<div className="bv-modal" onClick={e => e.stopPropagation()}>
<div className="bv-header">
<div className="bv-title-group">
<span className="bv-label">3D Modell</span>
<h3 className="bv-title">Bad</h3>
</div>
<button className="bv-close" onClick={onClose} aria-label="Schließen">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
</svg>
</button>
</div>
<div className="bv-canvas-wrap">
<canvas ref={canvasRef} className={`bv-canvas${ready ? ' bv-canvas--ready' : ''}`} />
{!ready && (
<div className="bv-loader" aria-label="Wird geladen">
<div className="bv-loader-ring" />
<span className="bv-loader-text">Modell wird geladen</span>
</div>
)}
</div>
<div className="bv-footer">
<span className="bv-hint">Ziehen zum Drehen</span>
<span className="bv-hint">Scrollen zum Zoomen</span>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,162 @@
.bdv-overlay {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(6, 6, 10, 0.82);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
animation: bdv-fade-in 0.22s ease;
}
@keyframes bdv-fade-in {
from { opacity: 0 }
to { opacity: 1 }
}
.bdv-modal {
width: 100%;
max-width: 640px;
height: min(580px, 90vh);
background: rgba(14, 14, 22, 0.96);
border: 1px solid rgba(139, 61, 184, 0.40);
border-radius: 16px;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow:
0 0 0 1px rgba(139, 61, 184, 0.12),
0 24px 80px rgba(0, 0, 0, 0.65),
0 0 60px rgba(139, 61, 184, 0.12);
animation: bdv-slide-up 0.28s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes bdv-slide-up {
from { opacity: 0; transform: translateY(24px) scale(0.97) }
to { opacity: 1; transform: translateY(0) scale(1) }
}
.bdv-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 1.5rem 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
flex-shrink: 0;
}
.bdv-title-group {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.bdv-label {
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--color-accent);
}
.bdv-title {
font-size: 1.1rem;
font-weight: 700;
color: var(--color-text);
letter-spacing: -0.01em;
}
.bdv-close {
width: 32px;
height: 32px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.10);
color: var(--color-text-muted);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background var(--transition), color var(--transition), border-color var(--transition);
flex-shrink: 0;
}
.bdv-close svg { width: 14px; height: 14px; }
.bdv-close:hover {
background: rgba(139, 61, 184, 0.22);
border-color: rgba(139, 61, 184, 0.50);
color: var(--color-text);
}
.bdv-canvas-wrap {
flex: 1;
position: relative;
overflow: hidden;
}
.bdv-canvas {
width: 100%;
height: 100%;
display: block;
cursor: grab;
opacity: 0;
transition: opacity 0.35s ease;
}
.bdv-canvas--ready { opacity: 1; }
.bdv-canvas:active { cursor: grabbing; }
.bdv-loader {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
pointer-events: none;
}
.bdv-loader-ring {
width: 40px;
height: 40px;
border-radius: 50%;
border: 2px solid rgba(139, 61, 184, 0.18);
border-top-color: var(--color-accent);
animation: bdv-spin 0.85s linear infinite;
}
@keyframes bdv-spin { to { transform: rotate(360deg); } }
.bdv-loader-text {
font-size: 0.72rem;
letter-spacing: 0.10em;
text-transform: uppercase;
color: var(--color-text-faint);
animation: bdv-pulse 1.6s ease-in-out infinite;
}
@keyframes bdv-pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 0.9; }
}
.bdv-footer {
display: flex;
align-items: center;
justify-content: center;
gap: 1.5rem;
padding: 0.75rem 1.5rem;
border-top: 1px solid rgba(255, 255, 255, 0.07);
flex-shrink: 0;
}
.bdv-hint {
font-size: 0.72rem;
color: var(--color-text-faint);
letter-spacing: 0.03em;
}

View File

@@ -0,0 +1,480 @@
import { useEffect, useRef, useState } from 'react'
import './BedroomViewer.css'
// =================================================================
// BEDROOM GEOMETRY
// =================================================================
function buildBedroom() {
const V = [], E = []
function ring(cx, y, cz, rx, rz, n = 24) {
const b = V.length
for (let i = 0; i < n; i++) {
const a = (i / n) * Math.PI * 2
V.push([cx + rx * Math.cos(a), y, cz + rz * Math.sin(a)])
}
for (let i = 0; i < n; i++) E.push([b + i, b + (i + 1) % n])
return b
}
function connectRings(b1, b2, n) {
for (let i = 0; i < n; i++) E.push([b1 + i, b2 + i])
}
function box(x1, y1, z1, x2, y2, z2) {
const b = V.length
V.push(
[x1,y1,z1],[x2,y1,z1],[x2,y1,z2],[x1,y1,z2],
[x1,y2,z1],[x2,y2,z1],[x2,y2,z2],[x1,y2,z2],
)
E.push(
[b,b+1],[b+1,b+2],[b+2,b+3],[b+3,b],
[b+4,b+5],[b+5,b+6],[b+6,b+7],[b+7,b+4],
[b,b+4],[b+1,b+5],[b+2,b+6],[b+3,b+7],
)
}
function line(x1,y1,z1, x2,y2,z2) {
const b = V.length
V.push([x1,y1,z1],[x2,y2,z2])
E.push([b,b+1])
}
function cylinder(cx, y1, cz, rx, rz, y2, n = 16) {
const b1 = ring(cx, y1, cz, rx, rz, n)
const b2 = ring(cx, y2, cz, rx, rz, n)
connectRings(b1, b2, n)
}
// ── ROOM ──────────────────────────────────────────────────────
const RX = 2.30, RZ = 2.50, FL = -0.50, CL = 1.34
const rc = V.length
V.push(
[-RX,FL,-RZ],[RX,FL,-RZ],[RX,FL, RZ],[-RX,FL, RZ],
[-RX,CL,-RZ],[RX,CL,-RZ],[RX,CL, RZ],[-RX,CL, RZ],
)
E.push(
[rc,rc+1],[rc+1,rc+2],[rc+2,rc+3],[rc+3,rc],
[rc+4,rc+5],[rc+5,rc+6],[rc+6,rc+7],[rc+7,rc+4],
[rc,rc+4],[rc+1,rc+5],[rc+2,rc+6],[rc+3,rc+7],
)
// ── DOOR (front wall, right side) ─────────────────────────────
{
const d = V.length
V.push([0.40,FL,-RZ],[0.40,FL+1.78,-RZ],[1.18,FL,-RZ],[1.18,FL+1.78,-RZ])
E.push([d,d+1],[d+2,d+3],[d+1,d+3])
}
{
const hw = 0.78, hh = 1.78, ang = 0.65
const fx = 0.40 + hw * Math.cos(ang), fz = -RZ + hw * Math.sin(ang)
const d = V.length
V.push([0.40,FL,-RZ],[0.40,FL+hh,-RZ],[fx,FL,fz],[fx,FL+hh,fz])
E.push([d,d+1],[d+2,d+3],[d,d+2],[d+1,d+3])
line(0.40, FL+0.89, -RZ, fx, FL+0.89, fz)
for (const hy of [FL+0.08, FL+0.89, FL+1.66]) box(0.38,hy,-RZ,0.42,hy+0.06,-RZ+0.04)
const hpx = 0.40 + 0.58*hw*Math.cos(ang), hpz = -RZ + 0.58*hw*Math.sin(ang)
box(hpx-0.025,FL+0.89,hpz-0.025,hpx+0.025,FL+0.99,hpz+0.025)
line(hpx,FL+0.99,hpz,hpx+0.05,FL+0.93,hpz-0.04)
}
// ── WINDOW (right wall x=RX, large) ───────────────────────────
{
const w = V.length
V.push([RX,0.56,-1.20],[RX,0.56,1.20],[RX,CL-0.10,-1.20],[RX,CL-0.10,1.20])
E.push([w,w+1],[w+2,w+3],[w,w+2],[w+1,w+3])
V.push([RX,0.56,0.00],[RX,CL-0.10,0.00]) // center bar
E.push([w+4,w+5])
V.push([RX,0.92,-1.20],[RX,0.92,1.20]) // mid bar
E.push([w+6,w+7])
box(RX-0.08,0.52,-1.24,RX,0.56,1.24) // sill
}
// ── CEILING LIGHT ──────────────────────────────────────────────
ring(0.0, CL, 0.10, 0.26, 0.26, 20)
ring(0.0, CL, 0.10, 0.10, 0.10, 14)
line(0.0, CL, 0.10, 0.0, CL-0.24, 0.10)
ring(0.0, CL-0.24, 0.10, 0.24, 0.24, 18)
ring(0.0, CL-0.34, 0.10, 0.16, 0.16, 14)
// ── DOUBLE BED (back wall z=RZ) ────────────────────────────────
const BX1 = -0.86, BX2 = 0.86, BZ1 = 0.72, BZ2 = 2.44
// Bed frame
box(BX1, FL, BZ1, BX2, FL+0.28, BZ2)
// Mattress
box(BX1+0.04, FL+0.28, BZ1+0.04, BX2-0.04, FL+0.44, BZ2-0.06)
// Headboard (tall, slatted)
box(BX1, FL+0.28, BZ2-0.06, BX2, FL+0.88, BZ2)
// Headboard slats (5 vertical lines)
for (let i = 1; i <= 4; i++) {
const sx = BX1 + (BX2 - BX1) * i / 5
line(sx, FL+0.32, BZ2-0.02, sx, FL+0.84, BZ2-0.02)
}
// Footboard
box(BX1, FL+0.28, BZ1, BX2, FL+0.46, BZ1+0.06)
// Duvet — two panels with centre seam and quilting lines
box(BX1+0.04, FL+0.44, BZ1+0.10, BX2-0.04, FL+0.50, BZ2-0.10)
line(0.0, FL+0.50, BZ1+0.10, 0.0, FL+0.50, BZ2-0.10) // centre seam
for (let i = 1; i <= 4; i++) {
const qz = BZ1 + 0.10 + (BZ2 - BZ2*0 - BZ1 - 0.20) * i / 5
line(BX1+0.06, FL+0.50, qz, BX2-0.06, FL+0.50, qz)
}
// Two pillows
box(BX1+0.06, FL+0.44, BZ2-0.38, -0.06, FL+0.56, BZ2-0.12)
box(0.06, FL+0.44, BZ2-0.38, BX2-0.06, FL+0.56, BZ2-0.12)
// Bed legs (4)
for (const [lx,lz] of [[BX1+0.08,BZ1+0.08],[BX2-0.08,BZ1+0.08],[BX1+0.08,BZ2-0.08],[BX2-0.08,BZ2-0.08]])
box(lx-0.04,FL-0.08,lz-0.04,lx+0.04,FL,lz+0.04)
// ── NIGHTSTANDS ────────────────────────────────────────────────
for (const [nx1, nx2] of [[BX1-0.40,BX1-0.04],[BX2+0.04,BX2+0.40]]) {
box(nx1, FL, BZ2-0.50, nx2, FL+0.54, BZ2-0.04)
// drawer line
line(nx1+0.02, FL+0.28, BZ2-0.48, nx2-0.02, FL+0.28, BZ2-0.06)
// small handle
const mx = (nx1+nx2)/2
box(mx-0.06,FL+0.38,BZ2-0.48,mx+0.06,FL+0.42,BZ2-0.46)
// table lamp
cylinder(mx, FL+0.54, BZ2-0.27, 0.020, 0.020, FL+0.78, 8)
ring(mx, FL+0.54, BZ2-0.27, 0.06, 0.06, 10)
ring(mx, FL+0.64, BZ2-0.27, 0.10, 0.10, 12)
ring(mx, FL+0.78, BZ2-0.27, 0.06, 0.06, 10)
{
const sa = ring(mx, FL+0.78, BZ2-0.27, 0.06, 0.06, 10)
const sb = ring(mx, FL+0.66, BZ2-0.27, 0.12, 0.12, 10)
connectRings(sa, sb, 10)
}
}
// ── WARDROBE (left wall, full height) ─────────────────────────
{
const WX1 = -RX, WX2 = -RX + 0.62
const WZ1 = -1.80, WZ2 = 2.42, WH = CL - 0.04
box(WX1, FL, WZ1, WX2, WH, WZ2)
box(WX1, WH, WZ1-0.01, WX2+0.01, WH+0.03, WZ2+0.01) // cornice
box(WX1, FL, WZ1, WX2, FL+0.07, WZ2) // kick plate
const seg = (WZ2 - WZ1) / 4
for (let i = 1; i < 4; i++) {
const dz = WZ1 + seg * i
line(WX2, FL+0.04, dz, WX2, WH-0.04, dz)
}
// handles + groove
for (let i = 0; i < 4; i++) {
const hz = WZ1 + seg*i + seg*0.5
box(WX2, 0.82, hz-0.03, WX2+0.02, 0.94, hz-0.01)
}
line(WX2, WH*0.66, WZ1, WX2, WH*0.66, WZ2)
}
// ── DRESSING TABLE (front wall left) ──────────────────────────
box(-RX, FL, -RZ+0.02, -RX+0.50, FL+0.76, -RZ+0.52)
// Table top
box(-RX-0.01, FL+0.74, -RZ+0.01, -RX+0.52, FL+0.78, -RZ+0.53)
// Drawer bank (facing door, on +x face at x = -RX+0.50)
for (let i = 0; i < 3; i++)
line(-RX+0.50, FL+0.16+i*0.18, -RZ+0.04, -RX+0.50, FL+0.16+i*0.18, -RZ+0.48)
// Handles
for (let i = 0; i < 3; i++)
box(-RX+0.50, FL+0.22+i*0.18, -RZ+0.30, -RX+0.54, FL+0.26+i*0.18, -RZ+0.22)
// Mirror above table
{
const m = V.length
V.push(
[-RX, FL+0.84, -RZ+0.04],[-RX, FL+0.84, -RZ+0.48],
[-RX, CL-0.10, -RZ+0.48],[-RX, CL-0.10, -RZ+0.04],
)
E.push([m,m+1],[m+1,m+2],[m+2,m+3],[m+3,m])
// mirror frame inner
const ins = 0.03
const n = V.length
V.push(
[-RX, FL+0.84+ins, -RZ+0.04+ins],[-RX, FL+0.84+ins, -RZ+0.48-ins],
[-RX, CL-0.10-ins, -RZ+0.48-ins],[-RX, CL-0.10-ins, -RZ+0.04+ins],
)
E.push([n,n+1],[n+1,n+2],[n+2,n+3],[n+3,n])
E.push([m,n],[m+1,n+1],[m+2,n+2],[m+3,n+3])
}
// ── WORKSPACE (under window, right wall) ──────────────────────
{
const DX1 = RX - 0.68, DX2 = RX - 0.04 // depth 0.64m from right wall
const DZ1 = -1.06, DZ2 = 1.06 // width 2.12m spanning under window
const DTY = FL + 0.76
// Desk surface
box(DX1, DTY, DZ1, DX2, DTY+0.04, DZ2)
// Legs (4 corners + centre support for wide span)
for (const [lx,lz] of [[DX1+0.06,DZ1+0.06],[DX2-0.06,DZ1+0.06],[DX1+0.06,DZ2-0.06],[DX2-0.06,DZ2-0.06]])
box(lx-0.03, FL, lz-0.03, lx+0.03, DTY, lz+0.03)
box(DX1+0.04, FL, -0.06, DX1+0.10, DTY, 0.06) // centre leg
// Back panel against wall
box(DX2, FL+0.08, DZ1, RX, DTY, DZ2)
// Under-desk shelf (right end)
box(DX1+0.04, FL+0.28, DZ2-0.42, DX2-0.02, FL+0.32, DZ2-0.06)
// Main monitor (centre, taller)
box(DX2-0.06, DTY+0.04, -0.32, DX2+0.02, DTY+0.40, 0.32) // screen
box(DX2-0.06, DTY+0.06, -0.29, DX2+0.01, DTY+0.38, 0.29) // bezel
cylinder(DX2-0.04, DTY+0.04, 0.0, 0.025, 0.025, DTY+0.16, 8)
box(DX2-0.16, DTY+0.04, -0.14, DX2+0.08, DTY+0.06, 0.14) // base
// Second monitor (right side, slightly smaller)
box(DX2-0.06, DTY+0.04, 0.52, DX2+0.02, DTY+0.32, 0.96)
box(DX2-0.06, DTY+0.06, 0.54, DX2+0.01, DTY+0.30, 0.94)
cylinder(DX2-0.04, DTY+0.04, 0.74, 0.022, 0.022, DTY+0.14, 8)
box(DX2-0.14, DTY+0.04, 0.64, DX2+0.06, DTY+0.06, 0.84)
// Keyboard + mouse
box(DX1+0.08, DTY+0.04, -0.22, DX1+0.44, DTY+0.06, 0.22)
box(DX1+0.08, DTY+0.04, 0.28, DX1+0.18, DTY+0.07, 0.40)
// PC tower (left end under desk)
box(DX1+0.04, FL, DZ1+0.08, DX1+0.22, FL+0.44, DZ1+0.30)
for (let i = 0; i < 3; i++)
line(DX1+0.04, FL+0.08+i*0.10, DZ1+0.08, DX1+0.04, FL+0.08+i*0.10, DZ1+0.30)
box(DX1+0.06, FL+0.38, DZ1+0.07, DX1+0.12, FL+0.42, DZ1+0.08) // power btn
// Desk lamp (left side of desk surface)
cylinder(DX1+0.10, DTY+0.04, DZ1+0.24, 0.018, 0.018, DTY+0.30, 8)
line(DX1+0.10, DTY+0.30, DZ1+0.24, DX1+0.28, DTY+0.32, DZ1+0.12)
box(DX1+0.22, DTY+0.28, DZ1+0.06, DX1+0.40, DTY+0.32, DZ1+0.18)
// Notepad / papers on desk
box(DX1+0.06, DTY+0.04, DZ2-0.34, DX1+0.26, DTY+0.055, DZ2-0.10)
line(DX1+0.08, DTY+0.055, DZ2-0.30, DX1+0.24, DTY+0.055, DZ2-0.30)
line(DX1+0.08, DTY+0.055, DZ2-0.24, DX1+0.24, DTY+0.055, DZ2-0.24)
}
// ── OFFICE CHAIR (in front of desk, facing right wall) ────────
{
const CX = RX - 1.14, CZ = 0.10
box(CX-0.24, FL+0.48, CZ-0.24, CX+0.24, FL+0.54, CZ+0.24) // seat
box(CX-0.24, FL+0.54, CZ-0.22, CX-0.14, FL+0.94, CZ+0.22) // backrest
line(CX-0.22, FL+0.68, CZ-0.20, CX-0.22, FL+0.68, CZ+0.20) // lumbar
box(CX-0.20, FL+0.64, CZ-0.28, CX+0.20, FL+0.68, CZ-0.24) // armrest L
box(CX-0.20, FL+0.64, CZ+0.24, CX+0.20, FL+0.68, CZ+0.28) // armrest R
cylinder(CX, FL+0.10, CZ, 0.028, 0.028, FL+0.48, 8)
for (let i = 0; i < 5; i++) {
const a = (i / 5) * Math.PI * 2
line(CX, FL+0.10, CZ, CX+0.28*Math.cos(a), FL+0.04, CZ+0.28*Math.sin(a))
}
ring(CX, FL+0.04, CZ, 0.28, 0.28, 20)
}
// ── CARPET (in front of bed) ────────────────────────────────────
ring(0.0, FL+0.01, 0.14, 1.10, 0.76, 40)
ring(0.0, FL+0.01, 0.14, 0.94, 0.62, 40)
// ── DECORATIVE ITEMS ───────────────────────────────────────────
// Small plant on nightstand (left)
cylinder(BX1-0.22, FL+0.54, BZ2-0.27, 0.06, 0.06, FL+0.68, 10)
ring(BX1-0.22, FL+0.68, BZ2-0.27, 0.08, 0.08, 10)
for (let i = 0; i < 5; i++) {
const a = (i / 5) * Math.PI * 2
line(BX1-0.22, FL+0.68, BZ2-0.27,
BX1-0.22 + 0.10*Math.cos(a), FL+0.84, BZ2-0.27 + 0.10*Math.sin(a))
}
// Picture on back wall above bed
{
const p = V.length
V.push(
[-0.44, FL+0.96, RZ],[ 0.44, FL+0.96, RZ],
[ 0.44, CL-0.12, RZ],[-0.44, CL-0.12, RZ],
)
E.push([p,p+1],[p+1,p+2],[p+2,p+3],[p+3,p])
const ins = 0.04, q = V.length
V.push(
[-0.44+ins, FL+0.96+ins, RZ],[ 0.44-ins, FL+0.96+ins, RZ],
[ 0.44-ins, CL-0.12-ins, RZ],[-0.44+ins, CL-0.12-ins, RZ],
)
E.push([q,q+1],[q+1,q+2],[q+2,q+3],[q+3,q])
E.push([p,q],[p+1,q+1],[p+2,q+2],[p+3,q+3])
// simple artwork lines
line(0.0, FL+1.04, RZ, -0.22, CL-0.20, RZ)
line(0.0, FL+1.04, RZ, 0.22, CL-0.20, RZ)
line(-0.30, FL+1.00, RZ, 0.30, FL+1.00, RZ)
}
return { verts: V, edges: E }
}
const { verts, edges } = buildBedroom()
// =================================================================
// ENGINE
// =================================================================
export default function BedroomViewer({ onClose }) {
const canvasRef = useRef(null)
const [ready, setReady] = useState(false)
const stateRef = useRef({
rotX: 0.30, rotY: -0.52,
dragging: false, lastX: 0, lastY: 0,
autoRotate: true, idleTimer: null,
zoom: 0.70, rafId: null, lastTs: 0,
})
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
const state = stateRef.current
const resize = () => {
const dpr = Math.min(window.devicePixelRatio || 1, 2)
canvas.width = canvas.offsetWidth * dpr
canvas.height = canvas.offsetHeight * dpr
}
resize()
const ro = new ResizeObserver(resize)
ro.observe(canvas)
function project(v) {
let [x, y, z] = v
const { rotX, rotY, zoom } = state
const dpr = Math.min(window.devicePixelRatio || 1, 2)
const W = canvas.width / dpr
const H = canvas.height / dpr
const cy_ = Math.cos(rotY), sy_ = Math.sin(rotY)
const x1 = x * cy_ - z * sy_
const z1 = x * sy_ + z * cy_
const cx_ = Math.cos(rotX), sx_ = Math.sin(rotX)
const y2 = y * cx_ - z1 * sx_
const z2 = y * sx_ + z1 * cx_
const fov = Math.min(W, H) * 1.75
const scale = Math.min(W, H) * 0.095 * zoom
const d = fov + z2
return { x: (x1/d)*fov*scale + W/2, y: (-y2/d)*fov*scale + H/2, z: z2 }
}
function render() {
const dpr = Math.min(window.devicePixelRatio || 1, 2)
const W = canvas.width / dpr
const H = canvas.height / dpr
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, W, H)
if (verts.length === 0) return
const proj = verts.map(v => project(v))
let zMin = Infinity, zMax = -Infinity
for (const p of proj) { if (p.z < zMin) zMin = p.z; if (p.z > zMax) zMax = p.z }
const zRange = zMax - zMin || 1
const sorted = edges
.map(([a, b]) => ({ a, b, depth: (proj[a].z + proj[b].z) * 0.5 }))
.sort((e1, e2) => e2.depth - e1.depth)
for (const { a, b, depth } of sorted) {
const pa = proj[a], pb = proj[b]
const t = (depth - zMin) / zRange
const da = Math.max(0.10, Math.min(1.0, 1.05 - t * 0.88))
ctx.beginPath(); ctx.moveTo(pa.x, pa.y); ctx.lineTo(pb.x, pb.y)
ctx.strokeStyle = `rgba(190,110,255,${da})`
ctx.lineWidth = 1.3; ctx.stroke()
}
for (const p of proj) {
const t = (p.z - zMin) / zRange
const da = Math.max(0.08, 0.45 - t * 0.35)
ctx.beginPath(); ctx.arc(p.x, p.y, 1.4, 0, Math.PI * 2)
ctx.fillStyle = `rgba(220,170,255,${da})`; ctx.fill()
}
}
let firstFrame = true
function loop(ts) {
state.rafId = requestAnimationFrame(loop)
if (ts - state.lastTs < 32) return
state.lastTs = ts
if (state.autoRotate && !state.dragging) state.rotY += 0.006
render()
if (firstFrame) { firstFrame = false; setReady(true) }
}
state.rafId = requestAnimationFrame(loop)
const stopAutoRotate = () => {
state.autoRotate = false
clearTimeout(state.idleTimer)
state.idleTimer = setTimeout(() => { state.autoRotate = true }, 2200)
}
const onMouseDown = (e) => { state.dragging=true; state.lastX=e.clientX; state.lastY=e.clientY; stopAutoRotate() }
const onMouseMove = (e) => {
if (!state.dragging) return
state.rotY += (e.clientX-state.lastX)*0.008; state.rotX += (e.clientY-state.lastY)*0.008
state.rotX = Math.max(-0.75,Math.min(0.75,state.rotX)); state.lastX=e.clientX; state.lastY=e.clientY
}
const onMouseUp = () => { state.dragging=false }
const onWheel = (e) => {
e.preventDefault(); state.zoom *= e.deltaY>0 ? 0.93 : 1.07
state.zoom = Math.max(0.35,Math.min(3.0,state.zoom)); stopAutoRotate()
}
let lastTX=0, lastTY=0, lastPinchDist=0
const onTouchStart = (e) => {
stopAutoRotate(); state.dragging=true
if (e.touches.length===1) { lastTX=e.touches[0].clientX; lastTY=e.touches[0].clientY }
else if (e.touches.length===2) lastPinchDist=Math.hypot(e.touches[0].clientX-e.touches[1].clientX,e.touches[0].clientY-e.touches[1].clientY)
}
const onTouchMove = (e) => {
if (!state.dragging) return
if (e.touches.length===1) {
state.rotY+=(e.touches[0].clientX-lastTX)*0.008; state.rotX+=(e.touches[0].clientY-lastTY)*0.008
state.rotX=Math.max(-0.75,Math.min(0.75,state.rotX)); lastTX=e.touches[0].clientX; lastTY=e.touches[0].clientY
} else if (e.touches.length===2) {
const dist=Math.hypot(e.touches[0].clientX-e.touches[1].clientX,e.touches[0].clientY-e.touches[1].clientY)
if (lastPinchDist) state.zoom*=dist/lastPinchDist
state.zoom=Math.max(0.35,Math.min(3.0,state.zoom)); lastPinchDist=dist
}
}
const onTouchEnd = () => { state.dragging=false; lastPinchDist=0 }
canvas.addEventListener('mousedown', onMouseDown)
window.addEventListener('mousemove', onMouseMove)
window.addEventListener('mouseup', onMouseUp)
canvas.addEventListener('wheel', onWheel, { passive: false })
canvas.addEventListener('touchstart', onTouchStart, { passive: true })
canvas.addEventListener('touchmove', onTouchMove, { passive: true })
canvas.addEventListener('touchend', onTouchEnd)
const onKeyDown = (e) => { if (e.key==='Escape') onClose() }
window.addEventListener('keydown', onKeyDown)
return () => {
cancelAnimationFrame(state.rafId); clearTimeout(state.idleTimer); ro.disconnect(); setReady(false)
canvas.removeEventListener('mousedown', onMouseDown); window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('mouseup', onMouseUp); canvas.removeEventListener('wheel', onWheel)
canvas.removeEventListener('touchstart', onTouchStart);canvas.removeEventListener('touchmove', onTouchMove)
canvas.removeEventListener('touchend', onTouchEnd); window.removeEventListener('keydown', onKeyDown)
}
}, [onClose])
return (
<div className="bdv-overlay" onClick={onClose}>
<div className="bdv-modal" onClick={e => e.stopPropagation()}>
<div className="bdv-header">
<div className="bdv-title-group">
<span className="bdv-label">3D Modell</span>
<h3 className="bdv-title">Schlafzimmer</h3>
</div>
<button className="bdv-close" onClick={onClose} aria-label="Schließen">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
</svg>
</button>
</div>
<div className="bdv-canvas-wrap">
<canvas ref={canvasRef} className={`bdv-canvas${ready ? ' bdv-canvas--ready' : ''}`} />
{!ready && (
<div className="bdv-loader" aria-label="Wird geladen">
<div className="bdv-loader-ring" />
<span className="bdv-loader-text">Modell wird geladen</span>
</div>
)}
</div>
<div className="bdv-footer">
<span className="bdv-hint">Ziehen zum Drehen</span>
<span className="bdv-hint">Scrollen zum Zoomen</span>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,162 @@
.skv-overlay {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(6, 6, 10, 0.82);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
animation: skv-fade-in 0.22s ease;
}
@keyframes skv-fade-in {
from { opacity: 0 }
to { opacity: 1 }
}
.skv-modal {
width: 100%;
max-width: 640px;
height: min(580px, 90vh);
background: rgba(14, 14, 22, 0.96);
border: 1px solid rgba(139, 61, 184, 0.40);
border-radius: 16px;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow:
0 0 0 1px rgba(139, 61, 184, 0.12),
0 24px 80px rgba(0, 0, 0, 0.65),
0 0 60px rgba(139, 61, 184, 0.12);
animation: skv-slide-up 0.28s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes skv-slide-up {
from { opacity: 0; transform: translateY(24px) scale(0.97) }
to { opacity: 1; transform: translateY(0) scale(1) }
}
.skv-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 1.5rem 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
flex-shrink: 0;
}
.skv-title-group {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.skv-label {
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--color-accent);
}
.skv-title {
font-size: 1.1rem;
font-weight: 700;
color: var(--color-text);
letter-spacing: -0.01em;
}
.skv-close {
width: 32px;
height: 32px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.10);
color: var(--color-text-muted);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background var(--transition), color var(--transition), border-color var(--transition);
flex-shrink: 0;
}
.skv-close svg { width: 14px; height: 14px; }
.skv-close:hover {
background: rgba(139, 61, 184, 0.22);
border-color: rgba(139, 61, 184, 0.50);
color: var(--color-text);
}
.skv-canvas-wrap {
flex: 1;
position: relative;
overflow: hidden;
}
.skv-canvas {
width: 100%;
height: 100%;
display: block;
cursor: grab;
opacity: 0;
transition: opacity 0.35s ease;
}
.skv-canvas--ready { opacity: 1; }
.skv-canvas:active { cursor: grabbing; }
.skv-loader {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
pointer-events: none;
}
.skv-loader-ring {
width: 40px;
height: 40px;
border-radius: 50%;
border: 2px solid rgba(139, 61, 184, 0.18);
border-top-color: var(--color-accent);
animation: skv-spin 0.85s linear infinite;
}
@keyframes skv-spin { to { transform: rotate(360deg); } }
.skv-loader-text {
font-size: 0.72rem;
letter-spacing: 0.10em;
text-transform: uppercase;
color: var(--color-text-faint);
animation: skv-pulse 1.6s ease-in-out infinite;
}
@keyframes skv-pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 0.9; }
}
.skv-footer {
display: flex;
align-items: center;
justify-content: center;
gap: 1.5rem;
padding: 0.75rem 1.5rem;
border-top: 1px solid rgba(255, 255, 255, 0.07);
flex-shrink: 0;
}
.skv-hint {
font-size: 0.72rem;
color: var(--color-text-faint);
letter-spacing: 0.03em;
}

View File

@@ -0,0 +1,317 @@
import { useEffect, useRef, useState } from 'react'
import './ClosetViewer.css'
// =================================================================
// FREESTANDING WARDROBE (Armoire) GEOMETRY
// =================================================================
function buildCloset() {
const V = [], E = []
function box(x1, y1, z1, x2, y2, z2) {
const b = V.length
V.push(
[x1,y1,z1],[x2,y1,z1],[x2,y1,z2],[x1,y1,z2],
[x1,y2,z1],[x2,y2,z1],[x2,y2,z2],[x1,y2,z2],
)
E.push(
[b,b+1],[b+1,b+2],[b+2,b+3],[b+3,b],
[b+4,b+5],[b+5,b+6],[b+6,b+7],[b+7,b+4],
[b,b+4],[b+1,b+5],[b+2,b+6],[b+3,b+7],
)
}
function line(x1,y1,z1, x2,y2,z2) {
const b = V.length
V.push([x1,y1,z1],[x2,y2,z2])
E.push([b,b+1])
}
// ── DIMENSIONS ────────────────────────────────────────────────
const WX = 0.76 // half-width → total 1.52 m
const WZ = 0.29 // half-depth → total 0.58 m
const WH = 2.10 // carcass height
const FH = 0.10 // foot height below carcass base
// ── FEET (4 block feet) ────────────────────────────────────────
for (const [fx, fz] of [
[-WX+0.14, -WZ+0.08], [ WX-0.14, -WZ+0.08],
[-WX+0.14, WZ-0.08], [ WX-0.14, WZ-0.08],
]) {
box(fx-0.04, -FH-0.04, fz-0.04, fx+0.04, -FH, fz+0.04)
box(fx-0.05, -FH, fz-0.05, fx+0.05, 0, fz+0.05)
}
// ── PLINTH (base rail, slightly proud of carcass) ──────────────
box(-WX-0.018, 0, -WZ-0.012, WX+0.018, 0.06, WZ+0.008)
box(-WX, 0.06, -WZ, WX, 0.08, WZ) // step in
// ── CARCASS SIDE PANELS ────────────────────────────────────────
const CB = 0.08 // carcass bottom (above plinth step)
const CT = WH // carcass top
box(-WX, CB, -WZ, -WX+0.022, CT, WZ)
box( WX-0.022, CB, -WZ, WX, CT, WZ)
// ── TOP & BOTTOM HORIZONTALS ───────────────────────────────────
box(-WX+0.022, CT-0.022, -WZ, WX-0.022, CT, WZ) // top panel
box(-WX+0.022, CB, -WZ, WX-0.022, CB+0.022, WZ) // bottom panel
// Back wall hint (just edges so depth reads cleanly)
line(-WX, CB, -WZ, WX, CB, -WZ)
line(-WX, CT, -WZ, WX, CT, -WZ)
line(-WX, CB, -WZ, -WX, CT, -WZ)
line( WX, CB, -WZ, WX, CT, -WZ)
// ── CORNICE (3-step decorative top molding) ────────────────────
box(-WX-0.018, CT, -WZ-0.012, WX+0.018, CT+0.048, WZ+0.006) // step 1
box(-WX-0.040, CT+0.048,-WZ-0.032, WX+0.040, CT+0.090, WZ+0.020) // step 2 (widest)
box(-WX-0.018, CT+0.090,-WZ-0.012, WX+0.018, CT+0.115, WZ+0.006) // step 3 (cap)
// ── DOORS ──────────────────────────────────────────────────────
const LDX1 = -WX+0.024, LDX2 = -0.013
const RDX1 = 0.013, RDX2 = WX-0.024
const DZ = WZ+0.018 // door sits just proud of carcass front
box(LDX1, CB+0.010, WZ, LDX2, CT-0.010, DZ)
box(RDX1, CB+0.010, WZ, RDX2, CT-0.010, DZ)
// Door raised panel detail: each door has 2 panels (upper + lower)
const pI = 0.055 // panel inset
const pMid = WH * 0.52 // horizontal mid-rail position
// Left door panels
box(LDX1+pI, CB+pI, WZ+0.002, LDX2-pI, pMid-0.012, DZ-0.002)
box(LDX1+pI, pMid+0.012, WZ+0.002, LDX2-pI, CT-pI, DZ-0.002)
// Right door panels
box(RDX1+pI, CB+pI, WZ+0.002, RDX2-pI, pMid-0.012, DZ-0.002)
box(RDX1+pI, pMid+0.012, WZ+0.002, RDX2-pI, CT-pI, DZ-0.002)
// ── HANDLES (vertical bar, inset toward centre gap) ────────────
const hY1 = 0.82, hY2 = 1.18
box(LDX2-0.050, hY1, WZ+0.018, LDX2-0.030, hY2, WZ+0.058)
box(RDX1+0.030, hY1, WZ+0.018, RDX1+0.050, hY2, WZ+0.058)
// Handle back brackets (top + bottom)
for (const hy of [hY1+0.04, hY2-0.04]) {
box(LDX2-0.052, hy-0.012, WZ+0.018, LDX2-0.028, hy+0.012, WZ+0.020)
box(RDX1+0.028, hy-0.012, WZ+0.018, RDX1+0.052, hy+0.012, WZ+0.020)
}
// ── HINGES ────────────────────────────────────────────────────
for (const hy of [CB+0.14, 1.05, CT-0.14]) {
box(LDX1-0.003, hy-0.026, WZ, LDX1+0.026, hy+0.026, WZ+0.010)
box(RDX2-0.026, hy-0.026, WZ, RDX2+0.003, hy+0.026, WZ+0.010)
}
// ── INTERIOR ────────────────────────────────────────────────────
// Mid shelf (splits interior into upper hanging + lower storage)
const MID_Y = 1.18
box(-WX+0.024, MID_Y, -WZ+0.016, WX-0.024, MID_Y+0.018, WZ-0.016)
// Upper: hanging rod
const rodY = MID_Y + 0.20 + (CT - MID_Y - 0.20) * 0.42
line(-WX+0.07, rodY, 0, WX-0.07, rodY, 0)
// 7 coat hangers on rod
for (let i = 0; i < 7; i++) {
const hx = -WX+0.14 + i * (WX*2 - 0.28) / 6
line(hx, rodY, 0, hx, rodY-0.04, 0)
line(hx, rodY-0.04, 0, hx-0.09, rodY-0.14, 0)
line(hx, rodY-0.04, 0, hx+0.09, rodY-0.14, 0)
line(hx-0.09, rodY-0.14, 0, hx+0.09, rodY-0.14, 0)
}
// Top inner shelf (above rod)
box(-WX+0.024, CT-0.19, -WZ+0.016, WX-0.024, CT-0.172, WZ-0.016)
// Lower: 2 drawers (front face lines)
const dh = (MID_Y - CB - 0.026) / 2
for (let i = 0; i < 2; i++) {
const dy1 = CB + 0.013 + i * dh
const dy2 = dy1 + dh
// Drawer front line (on back-plane as seen inside)
line(-WX+0.026, dy2, -WZ+0.018, WX-0.026, dy2, -WZ+0.018)
// Horizontal drawer handle
const mx = 0, hy_ = dy1 + dh*0.44
box(mx-0.12, hy_-0.012, WZ+0.019, mx+0.12, hy_+0.012, WZ+0.052)
}
// Small interior shelf in lower section
box(-WX+0.024, CB+0.55, -WZ+0.016, WX-0.024, CB+0.568, WZ-0.016)
return { verts: V, edges: E }
}
const { verts, edges } = buildCloset()
// =================================================================
// ENGINE
// =================================================================
export default function ClosetViewer({ onClose }) {
const canvasRef = useRef(null)
const [ready, setReady] = useState(false)
const stateRef = useRef({
rotX: 0.22, rotY: -0.40,
dragging: false, lastX: 0, lastY: 0,
autoRotate: true, idleTimer: null,
zoom: 0.92, rafId: null, lastTs: 0,
})
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
const state = stateRef.current
const resize = () => {
const dpr = Math.min(window.devicePixelRatio || 1, 2)
canvas.width = canvas.offsetWidth * dpr
canvas.height = canvas.offsetHeight * dpr
}
resize()
const ro = new ResizeObserver(resize)
ro.observe(canvas)
function project(v) {
let [x, y, z] = v
const { rotX, rotY, zoom } = state
const dpr = Math.min(window.devicePixelRatio || 1, 2)
const W = canvas.width / dpr
const H = canvas.height / dpr
const cy_ = Math.cos(rotY), sy_ = Math.sin(rotY)
const x1 = x * cy_ - z * sy_
const z1 = x * sy_ + z * cy_
const cx_ = Math.cos(rotX), sx_ = Math.sin(rotX)
const y2 = y * cx_ - z1 * sx_
const z2 = y * sx_ + z1 * cx_
const fov = Math.min(W, H) * 1.75
const scale = Math.min(W, H) * 0.095 * zoom
const d = fov + z2
return { x: (x1/d)*fov*scale + W/2, y: (-y2/d)*fov*scale + H/2, z: z2 }
}
function render() {
const dpr = Math.min(window.devicePixelRatio || 1, 2)
const W = canvas.width / dpr
const H = canvas.height / dpr
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, W, H)
if (verts.length === 0) return
const proj = verts.map(v => project(v))
let zMin = Infinity, zMax = -Infinity
for (const p of proj) { if (p.z < zMin) zMin = p.z; if (p.z > zMax) zMax = p.z }
const zRange = zMax - zMin || 1
const sorted = edges
.map(([a, b]) => ({ a, b, depth: (proj[a].z + proj[b].z) * 0.5 }))
.sort((e1, e2) => e2.depth - e1.depth)
for (const { a, b, depth } of sorted) {
const pa = proj[a], pb = proj[b]
const t = (depth - zMin) / zRange
const da = Math.max(0.10, Math.min(1.0, 1.05 - t * 0.88))
ctx.beginPath(); ctx.moveTo(pa.x, pa.y); ctx.lineTo(pb.x, pb.y)
ctx.strokeStyle = `rgba(190,110,255,${da})`
ctx.lineWidth = 1.3; ctx.stroke()
}
for (const p of proj) {
const t = (p.z - zMin) / zRange
const da = Math.max(0.08, 0.45 - t * 0.35)
ctx.beginPath(); ctx.arc(p.x, p.y, 1.4, 0, Math.PI * 2)
ctx.fillStyle = `rgba(220,170,255,${da})`; ctx.fill()
}
}
let firstFrame = true
function loop(ts) {
state.rafId = requestAnimationFrame(loop)
if (ts - state.lastTs < 32) return
state.lastTs = ts
if (state.autoRotate && !state.dragging) state.rotY += 0.006
render()
if (firstFrame) { firstFrame = false; setReady(true) }
}
state.rafId = requestAnimationFrame(loop)
const stopAutoRotate = () => {
state.autoRotate = false
clearTimeout(state.idleTimer)
state.idleTimer = setTimeout(() => { state.autoRotate = true }, 2200)
}
const onMouseDown = (e) => { state.dragging=true; state.lastX=e.clientX; state.lastY=e.clientY; stopAutoRotate() }
const onMouseMove = (e) => {
if (!state.dragging) return
state.rotY += (e.clientX-state.lastX)*0.008; state.rotX += (e.clientY-state.lastY)*0.008
state.rotX = Math.max(-0.75,Math.min(0.75,state.rotX)); state.lastX=e.clientX; state.lastY=e.clientY
}
const onMouseUp = () => { state.dragging=false }
const onWheel = (e) => {
e.preventDefault(); state.zoom *= e.deltaY>0 ? 0.93 : 1.07
state.zoom = Math.max(0.30,Math.min(3.0,state.zoom)); stopAutoRotate()
}
let lastTX=0, lastTY=0, lastPinchDist=0
const onTouchStart = (e) => {
stopAutoRotate(); state.dragging=true
if (e.touches.length===1) { lastTX=e.touches[0].clientX; lastTY=e.touches[0].clientY }
else if (e.touches.length===2) lastPinchDist=Math.hypot(e.touches[0].clientX-e.touches[1].clientX,e.touches[0].clientY-e.touches[1].clientY)
}
const onTouchMove = (e) => {
if (!state.dragging) return
if (e.touches.length===1) {
state.rotY+=(e.touches[0].clientX-lastTX)*0.008; state.rotX+=(e.touches[0].clientY-lastTY)*0.008
state.rotX=Math.max(-0.75,Math.min(0.75,state.rotX)); lastTX=e.touches[0].clientX; lastTY=e.touches[0].clientY
} else if (e.touches.length===2) {
const dist=Math.hypot(e.touches[0].clientX-e.touches[1].clientX,e.touches[0].clientY-e.touches[1].clientY)
if (lastPinchDist) state.zoom*=dist/lastPinchDist
state.zoom=Math.max(0.30,Math.min(3.0,state.zoom)); lastPinchDist=dist
}
}
const onTouchEnd = () => { state.dragging=false; lastPinchDist=0 }
canvas.addEventListener('mousedown', onMouseDown)
window.addEventListener('mousemove', onMouseMove)
window.addEventListener('mouseup', onMouseUp)
canvas.addEventListener('wheel', onWheel, { passive: false })
canvas.addEventListener('touchstart', onTouchStart, { passive: true })
canvas.addEventListener('touchmove', onTouchMove, { passive: true })
canvas.addEventListener('touchend', onTouchEnd)
const onKeyDown = (e) => { if (e.key==='Escape') onClose() }
window.addEventListener('keydown', onKeyDown)
return () => {
cancelAnimationFrame(state.rafId); clearTimeout(state.idleTimer); ro.disconnect(); setReady(false)
canvas.removeEventListener('mousedown', onMouseDown); window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('mouseup', onMouseUp); canvas.removeEventListener('wheel', onWheel)
canvas.removeEventListener('touchstart', onTouchStart);canvas.removeEventListener('touchmove', onTouchMove)
canvas.removeEventListener('touchend', onTouchEnd); window.removeEventListener('keydown', onKeyDown)
}
}, [onClose])
return (
<div className="skv-overlay" onClick={onClose}>
<div className="skv-modal" onClick={e => e.stopPropagation()}>
<div className="skv-header">
<div className="skv-title-group">
<span className="skv-label">3D Modell</span>
<h3 className="skv-title">Schrank / Einbaumöbel</h3>
</div>
<button className="skv-close" onClick={onClose} aria-label="Schließen">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
</svg>
</button>
</div>
<div className="skv-canvas-wrap">
<canvas ref={canvasRef} className={`skv-canvas${ready ? ' skv-canvas--ready' : ''}`} />
{!ready && (
<div className="skv-loader" aria-label="Wird geladen">
<div className="skv-loader-ring" />
<span className="skv-loader-text">Modell wird geladen</span>
</div>
)}
</div>
<div className="skv-footer">
<span className="skv-hint">Ziehen zum Drehen</span>
<span className="skv-hint">Scrollen zum Zoomen</span>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,192 @@
.contact {
background: rgba(18, 18, 18, 0.68);
border-top: 1px solid var(--color-border);
}
.contact-inner {
display: grid;
grid-template-columns: 1fr 420px;
gap: var(--space-16);
align-items: start;
}
/* Left copy */
.contact-copy {
display: flex;
flex-direction: column;
gap: var(--space-5);
}
.contact-label {
font-size: var(--font-size-xs);
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--color-accent);
font-weight: 600;
}
.contact-title {
font-size: clamp(2rem, 3.5vw, 3rem);
font-weight: 800;
letter-spacing: -0.03em;
color: var(--color-text);
line-height: 1.1;
}
.contact-body {
font-size: var(--font-size-md);
color: var(--color-text-muted);
line-height: 1.7;
max-width: 440px;
}
.contact-promises {
display: flex;
flex-direction: column;
gap: var(--space-4);
margin-top: var(--space-4);
}
.contact-promise {
display: flex;
align-items: center;
gap: var(--space-3);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
.contact-promise-icon {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--color-accent-dim);
border: 1px solid var(--color-accent-border);
display: flex;
align-items: center;
justify-content: center;
color: var(--color-accent);
flex-shrink: 0;
}
.contact-promise-icon svg {
width: 14px;
height: 14px;
}
/* Right card */
.contact-card {
background: var(--color-surface-2);
border: 1px solid var(--color-accent-border);
border-radius: var(--radius-xl);
padding: var(--space-8);
display: flex;
flex-direction: column;
gap: var(--space-5);
box-shadow: var(--shadow-accent);
}
.contact-card-top {
display: flex;
align-items: center;
gap: var(--space-4);
}
.contact-card-icon {
width: 52px;
height: 52px;
border-radius: var(--radius-lg);
background: var(--color-accent-dim);
border: 1px solid var(--color-accent-border);
display: flex;
align-items: center;
justify-content: center;
color: var(--color-accent);
flex-shrink: 0;
}
.contact-card-icon svg {
width: 28px;
height: 28px;
}
.contact-card-heading {
font-size: var(--font-size-lg);
font-weight: 700;
color: var(--color-text);
line-height: 1.2;
}
.contact-card-sub {
font-size: var(--font-size-xs);
color: var(--color-text-muted);
margin-top: var(--space-1);
}
.contact-book-btn {
width: 100%;
justify-content: center;
font-size: 1rem;
padding: 1rem 1.5rem;
gap: var(--space-3);
}
.contact-divider {
display: flex;
align-items: center;
gap: var(--space-3);
color: var(--color-text-faint);
font-size: var(--font-size-xs);
}
.contact-divider::before,
.contact-divider::after {
content: '';
flex: 1;
height: 1px;
background: var(--color-border);
}
.contact-direct {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.contact-direct-link {
display: flex;
align-items: center;
gap: var(--space-3);
padding: 0.75rem 1rem;
background: var(--color-surface-3);
border: 1px solid var(--color-border);
border-radius: var(--radius);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
transition: all var(--transition);
}
.contact-direct-link:hover {
border-color: var(--color-accent-border);
color: var(--color-text);
background: var(--color-accent-dim);
}
.contact-direct-link svg {
color: var(--color-accent);
flex-shrink: 0;
}
.contact-card-note {
font-size: var(--font-size-xs);
color: var(--color-text-faint);
text-align: center;
padding-top: var(--space-2);
border-top: 1px solid var(--color-border);
}
@media (max-width: 960px) {
.contact-inner {
grid-template-columns: 1fr;
max-width: 560px;
}
}

View File

@@ -0,0 +1,112 @@
import './Contact.css'
// Replace BOOKING_URL with your actual booking portal URL
const BOOKING_URL = 'https://termine.superfice.de'
export default function Contact() {
return (
<div className="contact section">
<div className="container">
<div className="contact-inner">
{/* Left: copy */}
<div className="contact-copy">
<span className="contact-label">Kontakt</span>
<h2 className="contact-title">Bereit für Ihr&nbsp;Aufmaß?</h2>
<p className="contact-body">
Buchen Sie Ihren Termin direkt online wählen Sie Datum, Uhrzeit und
Aufmaßbereiche in unserem Buchungsportal. Schnell, unkompliziert, verbindlich.
</p>
<div className="contact-promises">
<div className="contact-promise">
<div className="contact-promise-icon" aria-hidden="true">
<svg viewBox="0 0 20 20" fill="none">
<circle cx="10" cy="10" r="8" stroke="currentColor" strokeWidth="1.5"/>
<path d="M10 6v4l2.5 2" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
</svg>
</div>
Terminbestätigung innerhalb von Minuten
</div>
<div className="contact-promise">
<div className="contact-promise-icon" aria-hidden="true">
<svg viewBox="0 0 20 20" fill="none">
<path d="M4 10l4 4 8-8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
Aufmaß-Ergebnis innerhalb von 24 Stunden
</div>
<div className="contact-promise">
<div className="contact-promise-icon" aria-hidden="true">
<svg viewBox="0 0 20 20" fill="none">
<rect x="3" y="4" width="14" height="13" rx="2" stroke="currentColor" strokeWidth="1.5"/>
<path d="M3 9h14M7 4V2M13 4V2" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
</svg>
</div>
Flexibel stornierbar bis 24 h vorher
</div>
</div>
</div>
{/* Right: CTA card */}
<div className="contact-card">
<div className="contact-card-top">
<div className="contact-card-icon" aria-hidden="true">
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="6" y="10" width="36" height="32" rx="3" stroke="currentColor" strokeWidth="2"/>
<path d="M6 20h36" stroke="currentColor" strokeWidth="2"/>
<path d="M16 6v8M32 6v8" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
<rect x="14" y="28" width="6" height="6" rx="1" fill="currentColor" opacity="0.6"/>
<rect x="28" y="28" width="6" height="6" rx="1" fill="currentColor" opacity="0.3"/>
</svg>
</div>
<div>
<div className="contact-card-heading">Termin buchen</div>
<div className="contact-card-sub">Online · Sofort bestätigt</div>
</div>
</div>
<a
href={BOOKING_URL}
target="_blank"
rel="noopener noreferrer"
className="btn btn--primary contact-book-btn"
>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<rect x="2" y="3" width="14" height="13" rx="2" stroke="currentColor" strokeWidth="1.5"/>
<path d="M2 8h14" stroke="currentColor" strokeWidth="1.5"/>
<path d="M6 1v4M12 1v4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
</svg>
Jetzt Termin wählen
</a>
<div className="contact-divider">
<span>oder direkt melden</span>
</div>
<div className="contact-direct">
<a href="mailto:info@superfice.de" className="contact-direct-link">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<rect x="1" y="3" width="14" height="10" rx="2" stroke="currentColor" strokeWidth="1.5"/>
<path d="M1 5l7 5 7-5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
</svg>
info@superfice.de
</a>
<a href="tel:+4930000000" className="contact-direct-link">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M3 2h3l1.5 3.5-2 1.5a8 8 0 003.5 3.5l1.5-2L14 10v3a1 1 0 01-1 1C6 14 2 8 2 3a1 1 0 011-1z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round"/>
</svg>
+49 30 000000
</a>
</div>
<p className="contact-card-note">
MoFr, 818 Uhr · Antwort garantiert innerhalb von 24 h
</p>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,472 @@
.calc {
background: rgba(13, 13, 13, 0.55);
border-top: 1px solid var(--color-border);
}
.calc-header {
margin-bottom: var(--space-12);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.calc-label {
font-size: var(--font-size-xs);
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--color-accent);
font-weight: 600;
}
.calc-title {
font-size: clamp(1.75rem, 3vw, 2.5rem);
font-weight: 700;
letter-spacing: -0.02em;
color: var(--color-text);
}
.calc-subtitle {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
/* Card */
.calc-card {
max-width: 760px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
padding: var(--space-10);
display: flex;
flex-direction: column;
gap: var(--space-8);
}
/* Step indicator */
.calc-steps {
display: flex;
align-items: center;
gap: 0;
}
.calc-step {
display: flex;
align-items: center;
gap: var(--space-2);
flex: 1;
}
.calc-step-num {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-sm);
font-weight: 600;
background: var(--color-surface-2);
color: var(--color-text-muted);
border: 1px solid var(--color-border);
flex-shrink: 0;
transition: all var(--transition);
}
.calc-step-num svg {
width: 16px;
height: 16px;
}
.calc-step--active .calc-step-num {
background: var(--color-accent);
color: white;
border-color: var(--color-accent);
}
.calc-step--done .calc-step-num {
background: var(--color-accent-dim);
color: var(--color-accent);
border-color: var(--color-accent-border);
}
.calc-step-label {
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--color-text-muted);
transition: color var(--transition);
white-space: nowrap;
}
.calc-step--active .calc-step-label {
color: var(--color-text);
}
.calc-step-connector {
flex: 1;
height: 1px;
background: var(--color-border);
margin: 0 var(--space-3);
min-width: 16px;
}
/* Panel */
.calc-panel {
display: none;
flex-direction: column;
gap: var(--space-6);
}
.calc-panel--active {
display: flex;
}
.calc-panel-title {
font-size: var(--font-size-md);
font-weight: 600;
color: var(--color-text);
line-height: 1.4;
}
.calc-panel-title span {
color: var(--color-text-muted);
font-weight: 400;
}
.calc-panel-hint {
font-size: var(--font-size-xs);
color: var(--color-text-faint);
margin-top: calc(var(--space-6) * -1 + var(--space-2));
}
/* Service tiles */
.calc-service-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-3);
}
.calc-service-tile {
position: relative;
display: flex;
align-items: center;
gap: var(--space-3);
padding: 0.75rem var(--space-4);
background: var(--color-surface-2);
border: 1px solid var(--color-border);
border-radius: var(--radius);
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--color-text-muted);
cursor: pointer;
transition: all var(--transition);
text-align: left;
}
.calc-service-tile:hover {
border-color: var(--color-accent-border);
color: var(--color-text);
background: var(--color-surface-3);
}
.calc-service-tile--selected {
border-color: var(--color-accent) !important;
background: var(--color-accent-dim) !important;
color: var(--color-text) !important;
}
.calc-service-check {
width: 18px;
height: 18px;
border-radius: 4px;
border: 1.5px solid var(--color-border);
background: var(--color-surface);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all var(--transition);
color: var(--color-accent);
}
.calc-service-tile--selected .calc-service-check {
background: var(--color-accent);
border-color: var(--color-accent);
color: white;
}
.calc-service-check svg {
width: 12px;
height: 12px;
}
/* Quantities */
.calc-quantities {
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.calc-qty-row {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.calc-qty-label {
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--color-text);
}
.calc-qty-control {
display: flex;
align-items: center;
gap: var(--space-4);
}
.calc-qty-btn {
width: 36px;
height: 36px;
border-radius: var(--radius);
border: 1px solid var(--color-border);
background: var(--color-surface-2);
color: var(--color-text);
font-size: var(--font-size-lg);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all var(--transition);
line-height: 1;
}
.calc-qty-btn:hover:not(:disabled) {
border-color: var(--color-accent-border);
background: var(--color-accent-dim);
color: var(--color-accent);
}
.calc-qty-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.calc-qty-value {
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--color-text);
min-width: 120px;
text-align: center;
padding: 0.5rem 1rem;
background: var(--color-surface-2);
border: 1px solid var(--color-border);
border-radius: var(--radius);
}
/* Slider */
.calc-slider {
width: 100%;
max-width: 360px;
-webkit-appearance: none;
appearance: none;
height: 4px;
background: var(--color-surface-3);
border-radius: 2px;
outline: none;
}
.calc-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--color-accent);
cursor: pointer;
border: 2px solid var(--color-surface);
box-shadow: 0 0 0 2px var(--color-accent-border);
}
.calc-slider::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--color-accent);
cursor: pointer;
border: 2px solid var(--color-surface);
}
/* PLZ row */
.calc-plz-row {
display: flex;
gap: var(--space-4);
align-items: flex-start;
flex-wrap: wrap;
}
.calc-plz-input-wrap {
flex: 1;
min-width: 180px;
max-width: 280px;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.calc-plz-input {
width: 100%;
padding: 0.875rem 1rem;
background: var(--color-surface-2);
border: 1px solid var(--color-border);
border-radius: var(--radius);
color: var(--color-text);
font-size: var(--font-size-base);
transition: border-color var(--transition);
outline: none;
}
.calc-plz-input:focus {
border-color: var(--color-accent);
}
.calc-plz-input--error {
border-color: #b03030 !important;
}
.calc-plz-error {
font-size: var(--font-size-xs);
color: #e05454;
}
/* Result */
.calc-result {
background: var(--color-surface-2);
border: 1px solid var(--color-accent-border);
border-radius: var(--radius-lg);
padding: var(--space-8);
display: flex;
flex-direction: column;
gap: var(--space-6);
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.calc-result-breakdown {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding-bottom: var(--space-4);
border-bottom: 1px solid var(--color-border);
}
.calc-result-row {
display: flex;
justify-content: space-between;
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
.calc-result-row--note span:last-child {
color: var(--color-text-faint);
font-style: italic;
}
.calc-result-total {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.calc-result-price-label {
font-size: var(--font-size-xs);
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.1em;
font-weight: 500;
}
.calc-result-price {
font-size: clamp(2rem, 4vw, 3rem);
font-weight: 800;
letter-spacing: -0.03em;
color: var(--color-text);
display: flex;
align-items: baseline;
gap: var(--space-3);
}
.calc-result-tax {
font-size: var(--font-size-sm);
font-weight: 400;
color: var(--color-text-muted);
letter-spacing: 0;
}
.calc-result-cta {
align-self: flex-start;
}
.calc-result-hint {
font-size: var(--font-size-xs);
color: var(--color-text-faint);
line-height: 1.6;
border-top: 1px solid var(--color-border);
padding-top: var(--space-4);
}
.calc-result-individual {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
line-height: 1.6;
}
/* Nav */
.calc-nav {
display: flex;
gap: var(--space-4);
align-items: center;
padding-top: var(--space-2);
border-top: 1px solid var(--color-border);
}
/* Responsive */
@media (max-width: 768px) {
.calc-card {
padding: var(--space-6);
}
.calc-service-grid {
grid-template-columns: repeat(2, 1fr);
}
.calc-step-label {
display: none;
}
.calc-steps {
justify-content: center;
}
.calc-step {
flex: none;
}
.calc-plz-row {
flex-direction: column;
}
.calc-plz-input-wrap {
max-width: 100%;
width: 100%;
}
}
@media (max-width: 480px) {
.calc-service-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,381 @@
import { useState, useCallback } from 'react'
import {
BASE_PRICES,
QUANTITY_CONFIG,
calculateTotal,
calculateTravelCost,
calculateSubtotal,
hasIndividuell,
} from '../../utils/pricing'
import { fetchDistance } from '../../utils/api'
import './CostCalculator.css'
const BOOKING_URL = 'https://termin.superfice.de'
const SERVICE_TILES = [
{ id: 'kuche', label: 'Küche' },
{ id: 'raum', label: 'Raum / Grundriss' },
{ id: 'treppe', label: 'Treppe' },
{ id: 'schrank', label: 'Schrank / Einbaumöbel' },
{ id: 'bad', label: 'Bad' },
{ id: 'fenster', label: 'Fenster & Fassade' },
{ id: 'wintergarten', label: 'Wintergarten' },
{ id: 'individuell', label: 'Individuell' },
]
function StepIndicator({ current }) {
const steps = ['Was?', 'Wie viel?', 'Wo?']
return (
<div className="calc-steps">
{steps.map((label, i) => {
const step = i + 1
const done = current > step
const active = current === step
return (
<div key={step} className={`calc-step${active ? ' calc-step--active' : ''}${done ? ' calc-step--done' : ''}`}>
<div className="calc-step-num">
{done ? (
<svg viewBox="0 0 16 16" fill="none"><path d="M3 8l4 4 6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /></svg>
) : step}
</div>
<span className="calc-step-label">{label}</span>
{i < steps.length - 1 && <div className="calc-step-connector" />}
</div>
)
})}
</div>
)
}
export default function CostCalculator() {
const [step, setStep] = useState(1)
const [selectedServices, setSelectedServices] = useState([])
const [quantities, setQuantities] = useState({})
const [plz, setPlz] = useState('')
const [plzError, setPlzError] = useState('')
const [distance, setDistance] = useState(null)
const [loadingDist, setLoadingDist] = useState(false)
const [showResult, setShowResult] = useState(false)
const toggleService = (id) => {
setSelectedServices((prev) =>
prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id]
)
if (!quantities[id]) {
setQuantities((prev) => ({
...prev,
[id]: QUANTITY_CONFIG[id]?.min ?? 1,
}))
}
}
const setQty = (id, val) => {
const cfg = QUANTITY_CONFIG[id]
const clamped = Math.max(cfg.min, Math.min(cfg.max, Number(val)))
setQuantities((prev) => ({ ...prev, [id]: clamped }))
}
const handlePlzLookup = useCallback(async () => {
const cleaned = plz.replace(/\s/g, '')
if (!/^\d{5}$/.test(cleaned)) {
setPlzError('Bitte geben Sie eine gültige 5-stellige PLZ ein.')
return
}
setPlzError('')
setLoadingDist(true)
try {
const km = await fetchDistance(cleaned)
setDistance(km)
setShowResult(true)
} catch {
setPlzError('PLZ konnte nicht gefunden werden. Bitte prüfen Sie die Eingabe.')
} finally {
setLoadingDist(false)
}
}, [plz])
const handleKeyDown = (e) => {
if (e.key === 'Enter') handlePlzLookup()
}
const canGoStep2 = selectedServices.length > 0
const canGoStep3 = canGoStep2
const total = showResult
? calculateTotal(selectedServices, quantities, distance)
: null
const subtotal = calculateSubtotal(selectedServices, quantities)
const travelCost = calculateTravelCost(distance)
const isIndividuell = hasIndividuell(selectedServices)
const onlyIndividuell = selectedServices.length === 1 && isIndividuell
const buildBookingUrl = () => {
const params = new URLSearchParams()
if (plz) params.set('plz', plz)
if (total) params.set('preis', total)
selectedServices.forEach((id) => params.append('service', id))
return `${BOOKING_URL}?${params.toString()}`
}
return (
<div className="calc section">
<div className="container">
<div className="calc-header">
<span className="calc-label">Kostenrechner</span>
<h2 className="calc-title">Was kostet Ihr Aufmaß?</h2>
<p className="calc-subtitle">
Drei Schritte und Sie haben einen Richtwert für Ihr Projekt.
</p>
</div>
<div className="calc-card">
<StepIndicator current={step} />
{/* Step 1 */}
<div className={`calc-panel${step === 1 ? ' calc-panel--active' : ''}`}>
<h3 className="calc-panel-title">
Schritt 1 <span>Was möchten Sie aufmessen?</span>
</h3>
<p className="calc-panel-hint">Mehrfachauswahl möglich</p>
<div className="calc-service-grid">
{SERVICE_TILES.map(({ id, label }) => (
<button
key={id}
className={`calc-service-tile${selectedServices.includes(id) ? ' calc-service-tile--selected' : ''}`}
onClick={() => toggleService(id)}
type="button"
>
<div className="calc-service-check">
{selectedServices.includes(id) && (
<svg viewBox="0 0 12 12" fill="none">
<path d="M2 6l3 3 5-5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</div>
{label}
</button>
))}
</div>
<div className="calc-nav">
<button
className="btn btn--primary"
onClick={() => setStep(2)}
disabled={!canGoStep2}
>
Weiter
</button>
</div>
</div>
{/* Step 2 */}
<div className={`calc-panel${step === 2 ? ' calc-panel--active' : ''}`}>
<h3 className="calc-panel-title">
Schritt 2 <span>Wie viel?</span>
</h3>
{onlyIndividuell ? (
<p className="calc-panel-hint" style={{ marginBottom: '1.5rem' }}>
Ihr Projekt ist individuell. Geben Sie Ihre PLZ ein wir berechnen die Fahrtkosten und Sie buchen direkt Ihren Termin.
</p>
) : (
<div className="calc-quantities">
{selectedServices
.filter((id) => id !== 'individuell')
.map((id) => {
const cfg = QUANTITY_CONFIG[id]
const tile = SERVICE_TILES.find((t) => t.id === id)
const qty = quantities[id] ?? cfg.min
return (
<div key={id} className="calc-qty-row">
<label className="calc-qty-label">{cfg.label}</label>
<div className="calc-qty-control">
<button
className="calc-qty-btn"
onClick={() => setQty(id, qty - cfg.step)}
disabled={qty <= cfg.min}
aria-label="Weniger"
></button>
<span className="calc-qty-value">
{qty} {qty === 1 ? cfg.unit : cfg.unit + (cfg.unit.endsWith('e') ? 'n' : 'e')}
</span>
<button
className="calc-qty-btn"
onClick={() => setQty(id, qty + cfg.step)}
disabled={qty >= cfg.max}
aria-label="Mehr"
>+</button>
</div>
{id === 'fenster' && (
<input
type="range"
className="calc-slider"
min={cfg.min}
max={cfg.max}
step={cfg.step}
value={qty}
onChange={(e) => setQty(id, e.target.value)}
/>
)}
{id === 'raum' && (
<input
type="range"
className="calc-slider"
min={cfg.min}
max={cfg.max}
step={cfg.step}
value={qty}
onChange={(e) => setQty(id, e.target.value)}
/>
)}
</div>
)
})}
</div>
)}
<div className="calc-nav">
<button className="btn btn--ghost" onClick={() => setStep(1)}>
Zurück
</button>
<button
className="btn btn--primary"
onClick={() => setStep(3)}
disabled={!canGoStep3}
>
Weiter
</button>
</div>
</div>
{/* Step 3 */}
<div className={`calc-panel${step === 3 ? ' calc-panel--active' : ''}`}>
<h3 className="calc-panel-title">
Schritt 3 <span>Wo befindet sich das Objekt?</span>
</h3>
<p className="calc-panel-hint">
Wir berechnen die Fahrtkosten anhand der Entfernung zu unserem Standort.
</p>
<div className="calc-plz-row">
<div className="calc-plz-input-wrap">
<input
type="text"
className={`calc-plz-input${plzError ? ' calc-plz-input--error' : ''}`}
placeholder="PLZ eingeben, z. B. 10115"
value={plz}
onChange={(e) => {
setPlz(e.target.value)
setPlzError('')
setShowResult(false)
setDistance(null)
}}
onKeyDown={handleKeyDown}
maxLength={5}
inputMode="numeric"
pattern="[0-9]*"
/>
{plzError && <p className="calc-plz-error">{plzError}</p>}
</div>
<button
className="btn btn--primary"
onClick={handlePlzLookup}
disabled={loadingDist}
>
{loadingDist ? 'Prüfe…' : 'Berechnen'}
</button>
</div>
{showResult && !onlyIndividuell && (
<div className="calc-result">
<div className="calc-result-breakdown">
{selectedServices.filter((id) => id !== 'individuell').map((id) => {
const qty = quantities[id] ?? QUANTITY_CONFIG[id]?.min ?? 1
const tile = SERVICE_TILES.find((t) => t.id === id)
return (
<div key={id} className="calc-result-row">
<span>{tile?.label} × {qty}</span>
<span>{(BASE_PRICES[id] * qty).toLocaleString('de-DE')} </span>
</div>
)
})}
{isIndividuell && (
<div className="calc-result-row calc-result-row--note">
<span>Individuell</span>
<span>auf Anfrage</span>
</div>
)}
{travelCost > 0 && (
<div className="calc-result-row">
<span>Fahrtkosten ({distance} km)</span>
<span>{travelCost.toLocaleString('de-DE')} </span>
</div>
)}
{travelCost === 0 && distance !== null && (
<div className="calc-result-row calc-result-row--note">
<span>Fahrtkosten ({distance} km)</span>
<span>inklusive</span>
</div>
)}
</div>
<div className="calc-result-total">
<div className="calc-result-price-label">Geschätzter Preis ab</div>
<div className="calc-result-price">
{total.toLocaleString('de-DE')}
<span className="calc-result-tax">zzgl. MwSt.</span>
</div>
</div>
<a
href={buildBookingUrl()}
target="_blank"
rel="noopener noreferrer"
className="btn btn--primary calc-result-cta"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<rect x="1" y="2" width="14" height="13" rx="2" stroke="currentColor" strokeWidth="1.5"/>
<path d="M1 7h14" stroke="currentColor" strokeWidth="1.5"/>
<path d="M5 1v3M11 1v3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
</svg>
Jetzt Termin buchen
</a>
<p className="calc-result-hint">
Richtwert auf Basis typischer Projekte. Der finale Preis wird bei der Terminbuchung bestätigt.
</p>
</div>
)}
{showResult && onlyIndividuell && (
<div className="calc-result">
<p className="calc-result-individual">
Für individuelle Projekte erstellen wir Ihnen gern ein maßgeschneidertes Angebot. Buchen Sie direkt einen Beratungstermin.
</p>
<a
href={BOOKING_URL}
target="_blank"
rel="noopener noreferrer"
className="btn btn--primary calc-result-cta"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<rect x="1" y="2" width="14" height="13" rx="2" stroke="currentColor" strokeWidth="1.5"/>
<path d="M1 7h14" stroke="currentColor" strokeWidth="1.5"/>
<path d="M5 1v3M11 1v3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
</svg>
Beratungstermin buchen
</a>
</div>
)}
<div className="calc-nav">
<button className="btn btn--ghost" onClick={() => { setStep(2); setShowResult(false) }}>
Zurück
</button>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,27 @@
/* Hide native cursor on all elements when custom cursor is active */
html:has(#venom-cursor),
html:has(#venom-cursor) * {
cursor: none !important;
}
/* Canvas overlay — always on top */
#venom-cursor {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 2147483647;
}
/* Touch / coarse pointer — restore system cursor */
@media (pointer: coarse) {
html, html * {
cursor: auto !important;
}
#venom-cursor {
display: none !important;
}
}

View File

@@ -0,0 +1,231 @@
/**
* CustomCursor
* Adapted from Experiment_backup/scripts/cursor.js
* — Segmented spinning reticle
* — Tentacles that spawn on movement and stretch toward the cursor
* — Web connections between nearby anchor points
* — Idle: cold silver-white | Hover: Superfice purple #8B3DB8
* Desktop / fine-pointer only. Touch devices fall back to system cursor.
*/
import { useEffect } from 'react'
import './CustomCursor.css'
const CONFIG = {
tentacleCount: 10,
triggerDist: 8,
maxLength: 300,
connectionDist: 150,
prediction: 3.5,
// Idle — cold silver-white
idleStroke: { r: 210, g: 220, b: 255 },
idleGlow: { r: 160, g: 185, b: 255 },
// Hover — Superfice purple
hoverStroke: { r: 168, g: 85, b: 247 },
hoverGlow: { r: 139, g: 61, b: 184 },
}
const HOVER_SEL = [
'a', 'button', '[role="button"]', 'select', 'label', 'input',
'.btn', '.wwm-tile', '.calc-service-tile', '.calc-qty-btn',
'.calc-service-tile', '.footer-link', '.footer-legal-link',
'.contact-direct-link', '.contact-book-btn',
'[onclick]', '.clickable',
].join(',')
function lerpColor(a, b, t) {
return {
r: (a.r + (b.r - a.r) * t) | 0,
g: (a.g + (b.g - a.g) * t) | 0,
b: (a.b + (b.b - a.b) * t) | 0,
}
}
class Tentacle {
constructor(x, y) {
this.anchor = { x, y }
this.dead = false
this.dist = 0
this.age = 0
}
update(mx, my) {
const dx = mx - this.anchor.x
const dy = my - this.anchor.y
this.dist = Math.sqrt(dx * dx + dy * dy)
this.age++
if (this.dist > CONFIG.maxLength) this.dead = true
}
draw(ctx, mx, my, stroke, glow) {
if (this.dead) return
const tension = Math.min(this.dist / CONFIG.maxLength, 1)
const fadeIn = Math.min(this.age / 10, 1)
const lineAlpha = fadeIn * (1 - tension * 0.85) * 0.70
const dotAlpha = fadeIn * (1 - tension) * 0.90
const lw = Math.max(0.2, 1.1 * (1 - tension * 0.80))
// Glow pass
ctx.save()
ctx.shadowColor = `rgba(${glow.r},${glow.g},${glow.b},${lineAlpha * 0.55})`
ctx.shadowBlur = 6
ctx.beginPath()
ctx.moveTo(mx, my)
ctx.lineTo(this.anchor.x, this.anchor.y)
ctx.strokeStyle = `rgba(${stroke.r},${stroke.g},${stroke.b},${lineAlpha})`
ctx.lineWidth = lw
ctx.lineCap = 'round'
ctx.stroke()
ctx.restore()
// Anchor dot
ctx.beginPath()
ctx.arc(this.anchor.x, this.anchor.y, 1.8 * (1 - tension), 0, Math.PI * 2)
ctx.fillStyle = `rgba(${stroke.r},${stroke.g},${stroke.b},${dotAlpha})`
ctx.shadowColor = `rgba(${glow.r},${glow.g},${glow.b},${dotAlpha * 0.6})`
ctx.shadowBlur = 4
ctx.fill()
ctx.shadowBlur = 0
}
}
export default function CustomCursor({ enabled = true }) {
useEffect(() => {
// Touch / coarse pointer — skip entirely
if (window.matchMedia('(pointer: coarse)').matches) return
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
if (!enabled) return
// Canvas
const canvas = document.createElement('canvas')
canvas.id = 'venom-cursor'
document.body.appendChild(canvas)
const ctx = canvas.getContext('2d')
const resize = () => {
canvas.width = window.innerWidth
canvas.height = window.innerHeight
}
resize()
window.addEventListener('resize', resize)
// State
const mouse = { x: 0, y: 0 }
const oldMouse = { x: 0, y: 0 }
let isHover = false
let colorT = 0
let rotation = 0
const tentacles = []
let rafId
const onMouseMove = (e) => {
mouse.x = e.clientX
mouse.y = e.clientY
const el = document.elementFromPoint(e.clientX, e.clientY)
isHover = !!el && (el.matches(HOVER_SEL) || !!el.closest(HOVER_SEL))
}
// Hide native cursor
document.documentElement.style.cursor = 'none'
document.addEventListener('mousemove', onMouseMove, { passive: true })
function render() {
if (document.hidden) { rafId = requestAnimationFrame(render); return }
ctx.clearRect(0, 0, canvas.width, canvas.height)
// Lerp colour
colorT += ((isHover ? 1 : 0) - colorT) * 0.12
const stroke = lerpColor(CONFIG.idleStroke, CONFIG.hoverStroke, colorT)
const glow = lerpColor(CONFIG.idleGlow, CONFIG.hoverGlow, colorT)
// Spawn tentacle on movement
const moved = Math.hypot(mouse.x - oldMouse.x, mouse.y - oldMouse.y)
if (moved > CONFIG.triggerDist) {
const vx = mouse.x - oldMouse.x
const vy = mouse.y - oldMouse.y
const tx = mouse.x + vx * CONFIG.prediction + (Math.random() - 0.5) * 60
const ty = mouse.y + vy * CONFIG.prediction + (Math.random() - 0.5) * 60
tentacles.push(new Tentacle(tx, ty))
oldMouse.x = mouse.x
oldMouse.y = mouse.y
}
if (tentacles.length > CONFIG.tentacleCount) tentacles.shift()
// Update + draw tentacles
for (let i = tentacles.length - 1; i >= 0; i--) {
tentacles[i].update(mouse.x, mouse.y)
if (tentacles[i].dead) { tentacles.splice(i, 1); continue }
tentacles[i].draw(ctx, mouse.x, mouse.y, stroke, glow)
}
// Web connections between nearby anchors
for (let i = 0; i < tentacles.length; i++) {
for (let j = i + 1; j < tentacles.length; j++) {
const a = tentacles[i].anchor
const b = tentacles[j].anchor
const d = Math.hypot(a.x - b.x, a.y - b.y)
if (d < CONFIG.connectionDist) {
const alpha = (1 - d / CONFIG.connectionDist) * 0.28
ctx.beginPath()
ctx.moveTo(a.x, a.y)
ctx.lineTo(b.x, b.y)
ctx.strokeStyle = `rgba(${stroke.r},${stroke.g},${stroke.b},${alpha})`
ctx.lineWidth = 0.5
ctx.stroke()
}
}
}
// Segmented reticle ring
rotation += isHover ? 0.040 : 0.016
const ringR = isHover ? 9 : 7
const gapHalf = 0.28
const segs = [
[rotation + gapHalf, rotation + Math.PI * 0.5 - gapHalf],
[rotation + Math.PI * 0.5 + gapHalf, rotation + Math.PI - gapHalf],
[rotation + Math.PI + gapHalf, rotation + Math.PI * 1.5 - gapHalf],
[rotation + Math.PI * 1.5 + gapHalf, rotation + Math.PI * 2.0 - gapHalf],
]
ctx.save()
ctx.shadowColor = `rgba(${glow.r},${glow.g},${glow.b},0.75)`
ctx.shadowBlur = isHover ? 14 : 8
ctx.strokeStyle = `rgba(${stroke.r},${stroke.g},${stroke.b},0.90)`
ctx.lineWidth = isHover ? 1.4 : 1.1
ctx.lineCap = 'round'
segs.forEach(([start, end]) => {
ctx.beginPath()
ctx.arc(mouse.x, mouse.y, ringR, start, end)
ctx.stroke()
})
ctx.restore()
// Centre dot
ctx.save()
ctx.shadowColor = `rgba(${glow.r},${glow.g},${glow.b},0.90)`
ctx.shadowBlur = isHover ? 10 : 6
ctx.beginPath()
ctx.arc(mouse.x, mouse.y, isHover ? 2.2 : 1.6, 0, Math.PI * 2)
ctx.fillStyle = `rgba(${stroke.r},${stroke.g},${stroke.b},1)`
ctx.fill()
ctx.restore()
rafId = requestAnimationFrame(render)
}
render()
return () => {
cancelAnimationFrame(rafId)
window.removeEventListener('resize', resize)
document.removeEventListener('mousemove', onMouseMove)
document.documentElement.style.cursor = ''
canvas.remove()
}
}, [enabled])
return null // DOM element created imperatively
}

View File

@@ -0,0 +1,127 @@
.footer {
position: relative;
z-index: 1;
background: rgba(18, 18, 18, 0.82);
border-top: 1px solid var(--color-border);
padding: var(--space-16) 0 var(--space-10);
}
.footer-top {
display: flex;
justify-content: space-between;
gap: var(--space-12);
margin-bottom: var(--space-12);
padding-bottom: var(--space-10);
border-bottom: 1px solid var(--color-border);
}
.footer-brand {
display: flex;
flex-direction: column;
gap: var(--space-4);
max-width: 320px;
}
.footer-logo {
display: inline-flex;
}
.footer-logo-svg {
height: 26px;
width: auto;
}
.footer-tagline {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
line-height: 1.65;
}
.footer-network {
font-size: var(--font-size-xs);
color: var(--color-text-faint);
}
.footer-links {
display: flex;
gap: var(--space-16);
}
.footer-col {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.footer-col-label {
font-size: var(--font-size-xs);
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--color-text-muted);
margin-bottom: var(--space-1);
}
.footer-link {
font-size: var(--font-size-sm);
color: var(--color-text-faint);
background: none;
border: none;
cursor: pointer;
padding: 0;
text-align: left;
transition: color var(--transition);
}
.footer-link:hover {
color: var(--color-text);
}
.footer-bottom {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-6);
flex-wrap: wrap;
}
.footer-address {
font-size: var(--font-size-xs);
color: var(--color-text-faint);
}
.footer-legal {
display: flex;
align-items: center;
gap: var(--space-3);
font-size: var(--font-size-xs);
color: var(--color-text-faint);
}
.footer-legal-link {
color: var(--color-text-muted);
transition: color var(--transition);
}
.footer-legal-link:hover {
color: var(--color-text);
}
.footer-sep {
color: var(--color-text-faint);
}
@media (max-width: 768px) {
.footer-top {
flex-direction: column;
}
.footer-links {
gap: var(--space-8);
}
.footer-bottom {
flex-direction: column;
align-items: flex-start;
}
}

View File

@@ -0,0 +1,68 @@
import { Link, useLocation } from 'react-router-dom'
import './Footer.css'
const Logo = () => (
<svg viewBox="0 0 180 36" xmlns="http://www.w3.org/2000/svg" className="footer-logo-svg" aria-label="Superfice">
<rect x="0" y="6" width="3" height="24" rx="1.5" fill="#8B3DB8" />
<text x="10" y="27" fontFamily="Inter, sans-serif" fontSize="20" fontWeight="700" letterSpacing="2.5" fill="#F0F0F0">SUPERFICE</text>
</svg>
)
export default function Footer() {
const location = useLocation()
const scrollTo = (id) => {
if (location.pathname !== '/') {
window.location.href = `/#${id}`
return
}
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth' })
}
return (
<footer className="footer">
<div className="container">
<div className="footer-top">
<div className="footer-brand">
<Link to="/" className="footer-logo">
<Logo />
</Link>
<p className="footer-tagline">
Millimetergenaue 3D-Aufmaße für Handwerk, Innenausbau und Architektur.
</p>
<p className="footer-network">
Ein Unternehmen im Profice-Netzwerk.
</p>
</div>
<div className="footer-links">
<div className="footer-col">
<span className="footer-col-label">Leistungen</span>
<button className="footer-link" onClick={() => scrollTo('aufmass')}>Aufmaßbereiche</button>
<button className="footer-link" onClick={() => scrollTo('rechner')}>Kostenrechner</button>
<button className="footer-link" onClick={() => scrollTo('leistung')}>Ablauf</button>
</div>
<div className="footer-col">
<span className="footer-col-label">Unternehmen</span>
<button className="footer-link" onClick={() => scrollTo('warum')}>Warum Superfice</button>
<button className="footer-link" onClick={() => scrollTo('kontakt')}>Kontakt</button>
</div>
</div>
</div>
<div className="footer-bottom">
<div className="footer-address">
Superfice KG · Musterstraße 1 · 10115 Berlin · +49 30 000000 · info@superfice.de
</div>
<div className="footer-legal">
<Link to="/impressum" className="footer-legal-link">Impressum</Link>
<span className="footer-sep" aria-hidden="true">|</span>
<Link to="/datenschutz" className="footer-legal-link">Datenschutz</Link>
<span className="footer-sep" aria-hidden="true">·</span>
<span>© 2026 Superfice KG</span>
</div>
</div>
</div>
</footer>
)
}

View File

@@ -0,0 +1,204 @@
.hero {
position: relative;
min-height: 100vh;
display: flex;
align-items: center;
overflow: hidden;
}
.hero-bg {
position: absolute;
inset: 0;
z-index: 0;
}
.hero-grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(139, 61, 184, 0.1) 1px, transparent 1px),
linear-gradient(90deg, rgba(139, 61, 184, 0.1) 1px, transparent 1px);
background-size: 60px 60px;
mask-image: radial-gradient(ellipse 80% 80% at 50% 50%, black 40%, transparent 100%);
}
.hero-glow {
display: none; /* replaced by SpaceBackground nebulas */
}
.hero-content {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding-top: 8rem;
padding-bottom: 6rem;
gap: var(--space-6);
}
.hero-logo-mark {
margin-bottom: var(--space-2);
}
.hero-logo-mark svg {
width: 48px;
height: 48px;
}
.hero-label {
font-size: var(--font-size-xs);
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--color-accent);
font-weight: 600;
padding: 0.375rem 1rem;
border: 1px solid var(--color-accent-border);
border-radius: 100px;
background: var(--color-accent-dim);
}
.hero-headline {
font-size: clamp(2.75rem, 6vw, 5rem);
font-weight: 800;
line-height: 1.08;
letter-spacing: -0.03em;
color: var(--color-text);
max-width: 700px;
}
.hero-subline {
font-size: clamp(1rem, 1.8vw, 1.25rem);
color: var(--color-text-muted);
max-width: 560px;
line-height: 1.65;
}
.hero-actions {
display: flex;
gap: var(--space-4);
align-items: center;
margin-top: var(--space-2);
flex-wrap: wrap;
justify-content: center;
}
.hero-cta {
font-size: 1rem;
padding: 1rem 2.25rem;
gap: var(--space-3);
}
.hero-cta-secondary {
font-size: 1rem;
padding: 1rem 2rem;
}
/* Stats bar */
.hero-stats {
display: flex;
align-items: center;
gap: 0;
margin-top: var(--space-6);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: rgba(255,255,255,0.03);
overflow: hidden;
}
.hero-stat {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-1);
padding: var(--space-5) var(--space-8);
flex: 1;
border-right: 1px solid var(--color-border);
}
.hero-stat:last-child {
border-right: none;
}
.hero-stat-value {
font-size: var(--font-size-xl);
font-weight: 800;
letter-spacing: -0.03em;
color: var(--color-accent);
line-height: 1;
}
.hero-stat-label {
font-size: var(--font-size-xs);
color: var(--color-text-muted);
letter-spacing: 0.05em;
white-space: nowrap;
}
.hero-scroll {
position: absolute;
bottom: 2.5rem;
left: 50%;
transform: translateX(-50%);
}
.hero-scroll-line {
width: 1px;
height: 48px;
background: linear-gradient(to bottom, var(--color-accent), transparent);
animation: scrollPulse 2s ease-in-out infinite;
}
@keyframes scrollPulse {
0%, 100% { opacity: 0.3; transform: scaleY(1); }
50% { opacity: 1; transform: scaleY(1.1); }
}
@media (max-width: 768px) {
.hero-headline br {
display: none;
}
.hero-subline br {
display: none;
}
.hero-scroll {
display: none;
}
.hero-stats {
flex-wrap: wrap;
width: 100%;
}
.hero-stat {
flex: 1 1 45%;
min-width: 120px;
}
.hero-stat:nth-child(2) {
border-right: none;
}
.hero-stat:nth-child(3) {
border-top: 1px solid var(--color-border);
border-right: 1px solid var(--color-border);
}
.hero-stat:nth-child(4) {
border-top: 1px solid var(--color-border);
}
.hero-actions {
flex-direction: column;
width: 100%;
}
.hero-cta,
.hero-cta-secondary {
width: 100%;
justify-content: center;
}
}

View File

@@ -0,0 +1,77 @@
import './Hero.css'
const BOOKING_URL = 'https://termine.superfice.de'
const STATS = [
{ value: '±1 mm', label: 'Messgenauigkeit' },
{ value: '< 24 h', label: 'Lieferzeit' },
{ value: '8', label: 'Aufmaßbereiche' },
{ value: 'CAD', label: 'Direkt nutzbar' },
]
export default function Hero() {
return (
<div className="hero">
<div className="hero-bg">
<div className="hero-grid" />
<div className="hero-glow" />
</div>
<div className="hero-content container">
<div className="hero-logo-mark" aria-hidden="true">
<svg viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
<rect x="0" y="8" width="6" height="32" rx="3" fill="#8B3DB8" opacity="0.9" />
<rect x="10" y="16" width="6" height="24" rx="3" fill="#8B3DB8" opacity="0.6" />
<rect x="20" y="22" width="6" height="18" rx="3" fill="#8B3DB8" opacity="0.35" />
</svg>
</div>
<div className="hero-label">3D-Aufmaß · Handwerk · Architektur</div>
<h1 className="hero-headline">
Präzision beginnt<br />beim Aufmaß.
</h1>
<p className="hero-subline">
Millimetergenaue 3D-Aufmaße für Handwerk, Innenausbau und Architektur.
<br />Fertig aufbereitet für Ihre Planungssoftware.
</p>
<div className="hero-actions">
<a
href={BOOKING_URL}
target="_blank"
rel="noopener noreferrer"
className="btn btn--primary hero-cta"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<rect x="1" y="2" width="14" height="13" rx="2" stroke="currentColor" strokeWidth="1.5"/>
<path d="M1 7h14" stroke="currentColor" strokeWidth="1.5"/>
<path d="M5 1v3M11 1v3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
</svg>
Termin vereinbaren
</a>
<button
className="btn btn--outline hero-cta-secondary"
onClick={() => document.getElementById('rechner')?.scrollIntoView({ behavior: 'smooth' })}
>
Kosten berechnen
</button>
</div>
<div className="hero-stats" role="list">
{STATS.map((s) => (
<div key={s.value} className="hero-stat" role="listitem">
<span className="hero-stat-value">{s.value}</span>
<span className="hero-stat-label">{s.label}</span>
</div>
))}
</div>
<div className="hero-scroll" aria-hidden="true">
<div className="hero-scroll-line" />
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,162 @@
.kv-overlay {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(6, 6, 10, 0.82);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
animation: kv-fade-in 0.22s ease;
}
@keyframes kv-fade-in {
from { opacity: 0 }
to { opacity: 1 }
}
.kv-modal {
width: 100%;
max-width: 640px;
height: min(580px, 90vh);
background: rgba(14, 14, 22, 0.96);
border: 1px solid rgba(139, 61, 184, 0.40);
border-radius: 16px;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow:
0 0 0 1px rgba(139, 61, 184, 0.12),
0 24px 80px rgba(0, 0, 0, 0.65),
0 0 60px rgba(139, 61, 184, 0.12);
animation: kv-slide-up 0.28s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes kv-slide-up {
from { opacity: 0; transform: translateY(24px) scale(0.97) }
to { opacity: 1; transform: translateY(0) scale(1) }
}
.kv-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 1.5rem 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
flex-shrink: 0;
}
.kv-title-group {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.kv-label {
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--color-accent);
}
.kv-title {
font-size: 1.1rem;
font-weight: 700;
color: var(--color-text);
letter-spacing: -0.01em;
}
.kv-close {
width: 32px;
height: 32px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.10);
color: var(--color-text-muted);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background var(--transition), color var(--transition), border-color var(--transition);
flex-shrink: 0;
}
.kv-close svg { width: 14px; height: 14px; }
.kv-close:hover {
background: rgba(139, 61, 184, 0.22);
border-color: rgba(139, 61, 184, 0.50);
color: var(--color-text);
}
.kv-canvas-wrap {
flex: 1;
position: relative;
overflow: hidden;
}
.kv-canvas {
width: 100%;
height: 100%;
display: block;
cursor: grab;
opacity: 0;
transition: opacity 0.35s ease;
}
.kv-canvas--ready { opacity: 1; }
.kv-canvas:active { cursor: grabbing; }
.kv-loader {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
pointer-events: none;
}
.kv-loader-ring {
width: 40px;
height: 40px;
border-radius: 50%;
border: 2px solid rgba(139, 61, 184, 0.18);
border-top-color: var(--color-accent);
animation: kv-spin 0.85s linear infinite;
}
@keyframes kv-spin { to { transform: rotate(360deg); } }
.kv-loader-text {
font-size: 0.72rem;
letter-spacing: 0.10em;
text-transform: uppercase;
color: var(--color-text-faint);
animation: kv-pulse 1.6s ease-in-out infinite;
}
@keyframes kv-pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 0.9; }
}
.kv-footer {
display: flex;
align-items: center;
justify-content: center;
gap: 1.5rem;
padding: 0.75rem 1.5rem;
border-top: 1px solid rgba(255, 255, 255, 0.07);
flex-shrink: 0;
}
.kv-hint {
font-size: 0.72rem;
color: var(--color-text-faint);
letter-spacing: 0.03em;
}

View File

@@ -0,0 +1,578 @@
import { useEffect, useRef, useState } from 'react'
import './KitchenViewer.css'
// =================================================================
// KITCHEN GEOMETRY
// =================================================================
function buildKitchen() {
const V = [], E = []
function ring(cx, y, cz, rx, rz, n = 24) {
const b = V.length
for (let i = 0; i < n; i++) {
const a = (i / n) * Math.PI * 2
V.push([cx + rx * Math.cos(a), y, cz + rz * Math.sin(a)])
}
for (let i = 0; i < n; i++) E.push([b + i, b + (i + 1) % n])
return b
}
function connectRings(b1, b2, n) {
for (let i = 0; i < n; i++) E.push([b1 + i, b2 + i])
}
function box(x1, y1, z1, x2, y2, z2) {
const b = V.length
V.push(
[x1,y1,z1],[x2,y1,z1],[x2,y1,z2],[x1,y1,z2],
[x1,y2,z1],[x2,y2,z1],[x2,y2,z2],[x1,y2,z2],
)
E.push(
[b,b+1],[b+1,b+2],[b+2,b+3],[b+3,b],
[b+4,b+5],[b+5,b+6],[b+6,b+7],[b+7,b+4],
[b,b+4],[b+1,b+5],[b+2,b+6],[b+3,b+7],
)
}
function line(x1,y1,z1, x2,y2,z2) {
const b = V.length
V.push([x1,y1,z1],[x2,y2,z2])
E.push([b,b+1])
}
function cylinder(cx, y1, cz, rx, rz, y2, n = 16) {
const b1 = ring(cx, y1, cz, rx, rz, n)
const b2 = ring(cx, y2, cz, rx, rz, n)
connectRings(b1, b2, n)
}
// ── ROOM ──────────────────────────────────────────────────────
const RX = 1.75, RZ = 2.30, FL = -0.50, CL = 1.35
const rc = V.length
V.push(
[-RX,FL,-RZ],[RX,FL,-RZ],[RX,FL, RZ],[-RX,FL, RZ],
[-RX,CL,-RZ],[RX,CL,-RZ],[RX,CL, RZ],[-RX,CL, RZ],
)
E.push(
[rc,rc+1],[rc+1,rc+2],[rc+2,rc+3],[rc+3,rc],
[rc+4,rc+5],[rc+5,rc+6],[rc+6,rc+7],[rc+7,rc+4],
[rc,rc+4],[rc+1,rc+5],[rc+2,rc+6],[rc+3,rc+7],
)
// Door frame (front wall, left side)
{
const d = V.length
V.push([-1.38,FL,-RZ],[-1.38,FL+1.78,-RZ],[-0.62,FL,-RZ],[-0.62,FL+1.78,-RZ])
E.push([d,d+1],[d+2,d+3],[d+1,d+3])
}
// Door panel (open inward ~37°)
{
const hw = 0.76, hh = 1.78, ang = 0.65
const fx = -1.38 + hw * Math.cos(ang)
const fz = -RZ + hw * Math.sin(ang)
const d = V.length
V.push([-1.38,FL,-RZ],[-1.38,FL+hh,-RZ],[fx,FL,fz],[fx,FL+hh,fz])
E.push([d,d+1],[d+2,d+3],[d,d+2],[d+1,d+3])
line(-1.38, FL+0.89, -RZ, fx, FL+0.89, fz)
for (const hy of [FL+0.08, FL+0.89, FL+1.66]) {
box(-1.42, hy, -RZ, -1.38, hy+0.05, -RZ+0.04)
}
const hpx = -1.38 + 0.58*hw*Math.cos(ang)
const hpz = -RZ + 0.58*hw*Math.sin(ang)
box(hpx-0.025, FL+0.89, hpz-0.025, hpx+0.025, FL+0.99, hpz+0.025)
}
// Window above sink (back wall z=RZ)
{
const w = V.length
V.push([-1.50,0.70,RZ],[-0.62,0.70,RZ],[-1.50,1.18,RZ],[-0.62,1.18,RZ])
E.push([w,w+1],[w+2,w+3],[w,w+2],[w+1,w+3])
V.push([-1.06,0.70,RZ],[-1.06,1.18,RZ])
E.push([w+4,w+5])
V.push([-1.50,0.94,RZ],[-0.62,0.94,RZ])
E.push([w+6,w+7])
// sill
box(-1.54, 0.66, RZ-0.06, -0.58, 0.70, RZ)
}
// Small window (right wall x=RX)
{
const w = V.length
V.push([RX,0.42,-0.50],[RX,0.42,0.40],[RX,0.96,-0.50],[RX,0.96,0.40])
E.push([w,w+1],[w+2,w+3],[w,w+2],[w+1,w+3])
V.push([RX,0.69,-0.50],[RX,0.69,0.40])
E.push([w+4,w+5])
}
// Ceiling pendant (center)
ring(-0.10, CL, -0.20, 0.22, 0.22, 20)
ring(-0.10, CL, -0.20, 0.09, 0.09, 14)
line(-0.10, CL, -0.20, -0.10, CL-0.22, -0.20)
// ── CABINET DIMENSIONS ────────────────────────────────────────
const CTH = 0.40 // countertop y
const CD = 0.60 // lower cabinet depth
const CF = RZ - CD // 1.70 — lower cabinet front face
const UCB = 0.84 // upper cabinet bottom y
const UCT = 1.26 // upper cabinet top y
const UCF = RZ - 0.36 // 1.94 — upper cabinet front face
// ── BACK WALL LOWER CABINETS (full width) ─────────────────────
box(-RX, FL, CF, RX, CTH, RZ)
// Door dividers (6 vertical lines on front face)
for (const cx of [-1.25, -0.75, -0.25, 0.25, 0.75, 1.25]) {
line(cx, FL+0.04, CF, cx, CTH-0.04, CF)
}
// Door handles
for (let i = -3; i < 3; i++) {
const hx = (i + 0.5) * 0.50
box(hx-0.10, 0.06, CF-0.01, hx+0.10, 0.09, CF)
}
// Countertop slab
box(-RX, CTH, CF-0.04, RX, CTH+0.05, RZ)
// ── BACK WALL UPPER CABINETS (full width) ─────────────────────
box(-RX, UCB, UCF, RX, UCT, RZ)
// Door dividers
for (const cx of [-1.25, -0.75, -0.25, 0.25, 0.75, 1.25]) {
line(cx, UCB+0.04, UCF, cx, UCT-0.04, UCF)
}
// Handles
for (let i = -3; i < 3; i++) {
const hx = (i + 0.5) * 0.50
box(hx-0.08, UCB+0.06, UCF-0.01, hx+0.08, UCB+0.10, UCF)
}
// ── RIGHT WALL CABINETS (partial, z=0.70..2.30) ───────────────
const RWD = 0.60
const RWCF = RX - RWD // 1.15
box(RWCF, FL, 0.70, RX, CTH, RZ)
// Countertop
box(RWCF-0.04, CTH, 0.70, RX, CTH+0.05, RZ)
// Door dividers
for (const cz of [1.10, 1.60, 2.05]) {
line(RWCF, FL+0.04, cz, RWCF, CTH-0.04, cz)
}
// Upper cabinets right wall
box(RX-0.36, UCB, 0.82, RX, UCT, RZ)
for (const cz of [1.20, 1.72]) {
line(RX-0.36, UCB+0.04, cz, RX-0.36, UCT-0.04, cz)
}
// ── SINGLE BASIN SINK with draining board (back wall, left zone) ──
const SKCX = -1.06, SKCZ = RZ - CD * 0.5
// Outer countertop rim
box(SKCX-0.50, CTH+0.01, SKCZ-0.23, SKCX+0.24, CTH+0.04, SKCZ+0.23)
// Deep basin (right 55%)
box(SKCX-0.04, CTH+0.01, SKCZ-0.19, SKCX+0.20, CTH-0.13, SKCZ+0.19)
// Drain ring at basin bottom
ring(SKCX+0.10, CTH-0.12, SKCZ+0.10, 0.035, 0.035, 10)
// Draining board (left 45%, slightly raised flat surface)
box(SKCX-0.46, CTH+0.01, SKCZ-0.19, SKCX-0.04, CTH+0.025, SKCZ+0.19)
// 5 drainage grooves running front-to-back
for (let g = 0; g < 5; g++) {
const gx = SKCX - 0.42 + g * 0.075
line(gx, CTH+0.026, SKCZ-0.16, gx, CTH+0.026, SKCZ+0.16)
}
// ── Gooseneck faucet ──
{
const FX = SKCX - 0.06, FZbase = RZ - 0.08
cylinder(FX, CTH+0.03, FZbase, 0.024, 0.024, CTH+0.30, 8)
ring(FX, CTH+0.03, FZbase, 0.042, 0.042, 10)
// Bezier gooseneck curve in YZ plane (curves up then forward over basin)
const p0y = CTH+0.30, p0z = FZbase
const p1y = CTH+0.46, p1z = RZ - 0.24
const p2y = CTH+0.30, p2z = SKCZ
for (let i = 0; i < 10; i++) {
const t1 = i / 10, t2 = (i + 1) / 10
const by1 = (1-t1)**2*p0y + 2*(1-t1)*t1*p1y + t1**2*p2y
const bz1 = (1-t1)**2*p0z + 2*(1-t1)*t1*p1z + t1**2*p2z
const by2 = (1-t2)**2*p0y + 2*(1-t2)*t2*p1y + t2**2*p2y
const bz2 = (1-t2)**2*p0z + 2*(1-t2)*t2*p1z + t2**2*p2z
line(FX, by1, bz1, FX, by2, bz2)
}
// Spout tip pointing down into basin
line(FX, p2y, p2z, FX, p2y - 0.09, p2z)
ring(FX, p2y - 0.09, p2z, 0.018, 0.018, 8)
}
// P-trap drain
line(SKCX+0.10, CTH-0.13, SKCZ+0.10, SKCX+0.10, CTH-0.28, SKCZ+0.10)
line(SKCX+0.10, CTH-0.28, SKCZ+0.10, SKCX+0.10, CTH-0.28, SKCZ+0.18)
line(SKCX+0.10, CTH-0.28, SKCZ+0.18, SKCX+0.10, CTH-0.20, SKCZ+0.18)
line(SKCX+0.10, CTH-0.20, SKCZ+0.18, SKCX+0.10, CTH-0.20, RZ)
// Dishwasher (right of sink, in lower cabinet)
box(-0.52, FL+0.01, CF, -0.02, CTH-0.01, CF+0.02)
box(-0.46, CTH-0.07, CF-0.01, -0.08, CTH-0.04, CF) // handle
box(-0.50, CTH-0.12, CF, -0.04, CTH-0.04, CF+0.01) // control strip
// ── STOVE / COOKTOP (back wall, right-center) ─────────────────
const STOX = 0.55, STOZ = RZ - CD * 0.5
// Surface
box(STOX-0.40, CTH+0.01, STOZ-0.30, STOX+0.40, CTH+0.03, STOZ+0.30)
// 4 burner rings
for (const [bx, bz] of [
[STOX-0.20, STOZ-0.14],[STOX+0.20, STOZ-0.14],
[STOX-0.20, STOZ+0.14],[STOX+0.20, STOZ+0.14],
]) {
ring(bx, CTH+0.03, bz, 0.12, 0.12, 22)
ring(bx, CTH+0.03, bz, 0.055, 0.055, 12)
ring(bx, CTH+0.03, bz, 0.020, 0.020, 8)
}
// Control knobs (front row)
for (let i = 0; i < 4; i++) {
const kx = STOX - 0.27 + i * 0.18
cylinder(kx, CTH+0.03, CF+0.05, 0.030, 0.030, CTH+0.09, 8)
ring(kx, CTH+0.09, CF+0.05, 0.014, 0.014, 6)
}
// Oven (below stove)
box(STOX-0.40, FL+0.16, CF, STOX+0.40, CTH-0.04, CF+0.02)
box(STOX-0.34, FL+0.20, CF, STOX+0.34, CTH-0.08, CF+0.01) // window
box(STOX-0.28, CTH-0.08, CF-0.01, STOX+0.28, CTH-0.05, CF) // handle
// ── EXHAUST HOOD (above stove) ────────────────────────────────
const HDX = STOX, HDZ = STOZ
// Sloped hood body
box(HDX-0.42, CTH+0.60, HDZ-0.32, HDX+0.42, CTH+0.70, HDZ+0.32)
box(HDX-0.28, CTH+0.70, HDZ-0.22, HDX+0.28, UCB-0.02, HDZ+0.22)
box(HDX-0.14, UCB-0.02, HDZ-0.14, HDX+0.14, UCT+0.10, HDZ+0.14)
// Grill ring
ring(HDX, CTH+0.62, HDZ, 0.20, 0.20, 18)
// Under-hood lights
box(HDX-0.32, CTH+0.58, HDZ-0.07, HDX-0.14, CTH+0.62, HDZ+0.07)
box(HDX+0.14, CTH+0.58, HDZ-0.07, HDX+0.32, CTH+0.62, HDZ+0.07)
// Speed buttons
for (let i = 0; i < 3; i++) {
box(HDX-0.38+i*0.12, CTH+0.64, HDZ-0.32, HDX-0.30+i*0.12, CTH+0.68, HDZ-0.30)
}
// Microwave (upper cabinet, right of hood)
box(1.00, UCB+0.02, UCF, 1.62, UCB+0.24, UCF-0.28)
box(1.04, UCB+0.05, UCF, 1.48, UCB+0.21, UCF-0.01) // door glass
box(1.48, UCB+0.05, UCF, 1.60, UCB+0.21, UCF-0.01) // control panel
cylinder(1.54, UCB+0.13, UCF-0.01, 0.032, 0.032, UCB+0.17, 8) // dial
// ── REFRIGERATOR (right wall, near front) ─────────────────────
const RFX1 = RWCF - 0.02, RFX2 = RX
const RFZ1 = -1.90, RFZ2 = -1.10, RFZMID = (RFZ1 + RFZ2) * 0.5
const RFSPLIT = FL + 0.72
box(RFX1, FL, RFZ1, RFX2, FL+1.80, RFZ2)
// Horizontal split (freezer bottom / fridge top) on door face
line(RFX1, RFSPLIT, RFZ1, RFX1, RFSPLIT, RFZ2)
// Vertical center seam on upper French doors
line(RFX1, RFSPLIT, RFZMID, RFX1, FL+1.80, RFZMID)
// Upper door handles (left and right halves)
box(RFX1-0.02, FL+1.20, RFZ1+0.08, RFX1, FL+1.34, RFZ1+0.10)
box(RFX1-0.02, FL+1.20, RFZMID+0.04, RFX1, FL+1.34, RFZMID+0.06)
// Lower freezer handle
box(RFX1-0.02, FL+0.38, RFZ1+0.08, RFX1, FL+0.52, RFZ1+0.10)
// Ice/water dispenser panel on left upper door
box(RFX1-0.03, FL+0.84, RFZ1+0.10, RFX1, FL+1.10, RFZ1+0.32)
for (let i = 0; i < 3; i++) {
box(RFX1-0.015, FL+1.04-i*0.065, RFZ1+0.13, RFX1-0.005, FL+1.08-i*0.065, RFZ1+0.20)
}
// Dispenser outlet recess
box(RFX1-0.03, FL+0.84, RFZ1+0.14, RFX1-0.01, FL+0.90, RFZ1+0.28)
// Temperature display (top center)
box(RFX1-0.02, FL+1.64, RFZMID-0.07, RFX1, FL+1.70, RFZMID+0.07)
// Bottom ventilation grille (4 horizontal slots)
for (let g = 0; g < 4; g++) {
line(RFX1, FL+0.03+g*0.05, RFZ1, RFX1, FL+0.03+g*0.05, RFZ2)
}
// Top vent strip
box(RFX1, FL+1.72, RFZ1+0.02, RFX2, FL+1.78, RFZ2-0.02)
// Hinges on far side (z=RFZ2)
for (const hy of [RFSPLIT+0.06, FL+1.62]) {
box(RFX2-0.04, hy, RFZ2-0.08, RFX2, hy+0.08, RFZ2)
}
box(RFX2-0.04, FL+0.10, RFZ2-0.08, RFX2, FL+0.18, RFZ2)
box(RFX2-0.04, RFSPLIT-0.08, RFZ2-0.08, RFX2, RFSPLIT, RFZ2)
// Interior shelf edge visible at split
line(RFX1, RFSPLIT+0.06, RFZ1+0.02, RFX2, RFSPLIT+0.06, RFZ1+0.02)
// Brand logo area
box(RFX1-0.01, FL+1.52, RFZMID-0.10, RFX1, FL+1.56, RFZMID+0.10)
// ── KITCHEN ISLAND (center room) ──────────────────────────────
const ISX = 0.0, ISZ = -0.15
box(ISX-0.60, FL, ISZ-0.32, ISX+0.60, CTH, ISZ+0.32)
// Countertop overhang (front for stools)
box(ISX-0.62, CTH, ISZ-0.50, ISX+0.62, CTH+0.05, ISZ+0.34)
// Cabinet door lines
line(ISX, FL+0.04, ISZ+0.32, ISX, CTH-0.04, ISZ+0.32)
line(ISX, FL+0.04, ISZ-0.32, ISX, CTH-0.04, ISZ-0.32)
// Island handle
box(ISX-0.18, 0.14, ISZ+0.33, ISX+0.18, 0.18, ISZ+0.32)
// Pendant lights above island
for (const px of [ISX-0.30, ISX+0.30]) {
line(px, CL, ISZ+0.05, px, CL-0.30, ISZ+0.05)
ring(px, CL-0.30, ISZ+0.05, 0.09, 0.09, 14)
ring(px, CL-0.38, ISZ+0.05, 0.06, 0.06, 10)
}
// ── DETAILS ───────────────────────────────────────────────────
// Knife block on counter
box(1.38, CTH+0.05, CF+0.06, 1.56, CTH+0.22, CF+0.14)
// 4 knife handles sticking up
for (let i = 0; i < 4; i++) {
line(1.40+i*0.04, CTH+0.22, CF+0.10, 1.38+i*0.04, CTH+0.40, CF+0.10)
}
// Fruit bowl on island
ring(ISX+0.22, CTH+0.05, ISZ+0.10, 0.12, 0.12, 18)
ring(ISX+0.22, CTH+0.05, ISZ+0.10, 0.07, 0.07, 14)
// Towel rail on oven handle area
line(STOX-0.28, FL+0.08, CF-0.01, STOX+0.28, FL+0.08, CF-0.01)
// Paper towel holder on counter (right side)
cylinder(1.30, CTH+0.05, CF+0.10, 0.025, 0.025, CTH+0.24, 8)
ring(1.30, CTH+0.24, CF+0.10, 0.06, 0.06, 12)
return { verts: V, edges: E }
}
const { verts, edges } = buildKitchen()
// =================================================================
// ENGINE — do not touch below this line
// =================================================================
export default function KitchenViewer({ onClose }) {
const canvasRef = useRef(null)
const [ready, setReady] = useState(false)
const stateRef = useRef({
rotX: 0.32, rotY: -0.50,
dragging: false, lastX: 0, lastY: 0,
autoRotate: true, idleTimer: null,
zoom: 0.72, rafId: null, lastTs: 0,
})
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
const state = stateRef.current
const resize = () => {
const dpr = Math.min(window.devicePixelRatio || 1, 2)
canvas.width = canvas.offsetWidth * dpr
canvas.height = canvas.offsetHeight * dpr
}
resize()
const ro = new ResizeObserver(resize)
ro.observe(canvas)
function project(v) {
let [x, y, z] = v
const { rotX, rotY, zoom } = state
const dpr = Math.min(window.devicePixelRatio || 1, 2)
const W = canvas.width / dpr
const H = canvas.height / dpr
const cy_ = Math.cos(rotY), sy_ = Math.sin(rotY)
const x1 = x * cy_ - z * sy_
const z1 = x * sy_ + z * cy_
const cx_ = Math.cos(rotX), sx_ = Math.sin(rotX)
const y2 = y * cx_ - z1 * sx_
const z2 = y * sx_ + z1 * cx_
const fov = Math.min(W, H) * 1.75
const scale = Math.min(W, H) * 0.095 * zoom
const d = fov + z2
return {
x: (x1 / d) * fov * scale + W / 2,
y: (-y2 / d) * fov * scale + H / 2,
z: z2,
}
}
function render() {
const dpr = Math.min(window.devicePixelRatio || 1, 2)
const W = canvas.width / dpr
const H = canvas.height / dpr
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, W, H)
if (verts.length === 0) return
const proj = verts.map(v => project(v))
let zMin = Infinity, zMax = -Infinity
for (const p of proj) {
if (p.z < zMin) zMin = p.z
if (p.z > zMax) zMax = p.z
}
const zRange = zMax - zMin || 1
const sorted = edges
.map(([a, b]) => ({ a, b, depth: (proj[a].z + proj[b].z) * 0.5 }))
.sort((e1, e2) => e2.depth - e1.depth)
for (const { a, b, depth } of sorted) {
const pa = proj[a]
const pb = proj[b]
const t = (depth - zMin) / zRange
const da = Math.max(0.10, Math.min(1.0, 1.05 - t * 0.88))
ctx.beginPath()
ctx.moveTo(pa.x, pa.y)
ctx.lineTo(pb.x, pb.y)
ctx.strokeStyle = `rgba(190,110,255,${da})`
ctx.lineWidth = 1.3
ctx.stroke()
}
for (const p of proj) {
const t = (p.z - zMin) / zRange
const da = Math.max(0.08, 0.45 - t * 0.35)
ctx.beginPath()
ctx.arc(p.x, p.y, 1.4, 0, Math.PI * 2)
ctx.fillStyle = `rgba(220,170,255,${da})`
ctx.fill()
}
}
let firstFrame = true
function loop(ts) {
state.rafId = requestAnimationFrame(loop)
if (ts - state.lastTs < 32) return
state.lastTs = ts
if (state.autoRotate && !state.dragging) state.rotY += 0.006
render()
if (firstFrame) { firstFrame = false; setReady(true) }
}
state.rafId = requestAnimationFrame(loop)
const stopAutoRotate = () => {
state.autoRotate = false
clearTimeout(state.idleTimer)
state.idleTimer = setTimeout(() => { state.autoRotate = true }, 2200)
}
const onMouseDown = (e) => {
state.dragging = true
state.lastX = e.clientX
state.lastY = e.clientY
stopAutoRotate()
}
const onMouseMove = (e) => {
if (!state.dragging) return
state.rotY += (e.clientX - state.lastX) * 0.008
state.rotX += (e.clientY - state.lastY) * 0.008
state.rotX = Math.max(-0.75, Math.min(0.75, state.rotX))
state.lastX = e.clientX
state.lastY = e.clientY
}
const onMouseUp = () => { state.dragging = false }
const onWheel = (e) => {
e.preventDefault()
state.zoom *= e.deltaY > 0 ? 0.93 : 1.07
state.zoom = Math.max(0.35, Math.min(3.0, state.zoom))
stopAutoRotate()
}
let lastTX = 0, lastTY = 0, lastPinchDist = 0
const onTouchStart = (e) => {
stopAutoRotate()
state.dragging = true
if (e.touches.length === 1) {
lastTX = e.touches[0].clientX
lastTY = e.touches[0].clientY
} else if (e.touches.length === 2) {
lastPinchDist = Math.hypot(
e.touches[0].clientX - e.touches[1].clientX,
e.touches[0].clientY - e.touches[1].clientY,
)
}
}
const onTouchMove = (e) => {
if (!state.dragging) return
if (e.touches.length === 1) {
state.rotY += (e.touches[0].clientX - lastTX) * 0.008
state.rotX += (e.touches[0].clientY - lastTY) * 0.008
state.rotX = Math.max(-0.75, Math.min(0.75, state.rotX))
lastTX = e.touches[0].clientX
lastTY = e.touches[0].clientY
} else if (e.touches.length === 2) {
const dist = Math.hypot(
e.touches[0].clientX - e.touches[1].clientX,
e.touches[0].clientY - e.touches[1].clientY,
)
if (lastPinchDist) state.zoom *= dist / lastPinchDist
state.zoom = Math.max(0.35, Math.min(3.0, state.zoom))
lastPinchDist = dist
}
}
const onTouchEnd = () => { state.dragging = false; lastPinchDist = 0 }
canvas.addEventListener('mousedown', onMouseDown)
window.addEventListener('mousemove', onMouseMove)
window.addEventListener('mouseup', onMouseUp)
canvas.addEventListener('wheel', onWheel, { passive: false })
canvas.addEventListener('touchstart', onTouchStart, { passive: true })
canvas.addEventListener('touchmove', onTouchMove, { passive: true })
canvas.addEventListener('touchend', onTouchEnd)
const onKeyDown = (e) => { if (e.key === 'Escape') onClose() }
window.addEventListener('keydown', onKeyDown)
return () => {
cancelAnimationFrame(state.rafId)
clearTimeout(state.idleTimer)
ro.disconnect()
setReady(false)
canvas.removeEventListener('mousedown', onMouseDown)
window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('mouseup', onMouseUp)
canvas.removeEventListener('wheel', onWheel)
canvas.removeEventListener('touchstart', onTouchStart)
canvas.removeEventListener('touchmove', onTouchMove)
canvas.removeEventListener('touchend', onTouchEnd)
window.removeEventListener('keydown', onKeyDown)
}
}, [onClose])
return (
<div className="kv-overlay" onClick={onClose}>
<div className="kv-modal" onClick={e => e.stopPropagation()}>
<div className="kv-header">
<div className="kv-title-group">
<span className="kv-label">3D Modell</span>
<h3 className="kv-title">Küche</h3>
</div>
<button className="kv-close" onClick={onClose} aria-label="Schließen">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
</svg>
</button>
</div>
<div className="kv-canvas-wrap">
<canvas ref={canvasRef} className={`kv-canvas${ready ? ' kv-canvas--ready' : ''}`} />
{!ready && (
<div className="kv-loader" aria-label="Wird geladen">
<div className="kv-loader-ring" />
<span className="kv-loader-text">Modell wird geladen</span>
</div>
)}
</div>
<div className="kv-footer">
<span className="kv-hint">Ziehen zum Drehen</span>
<span className="kv-hint">Scrollen zum Zoomen</span>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,257 @@
.nav {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
transition: background var(--transition), box-shadow var(--transition);
}
.nav--scrolled {
background: rgba(11, 11, 11, 0.92);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
box-shadow: 0 1px 0 var(--color-border);
}
.nav-inner {
max-width: var(--max-width);
margin: 0 auto;
padding: 0 var(--section-padding-x);
height: 68px;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
}
.nav-logo {
display: flex;
align-items: center;
flex-shrink: 0;
}
.nav-logo-svg {
height: 28px;
width: auto;
}
.nav-hamburger {
display: none;
flex-direction: column;
gap: 5px;
padding: 4px;
background: none;
border: none;
cursor: pointer;
}
.nav-hamburger span {
display: block;
width: 22px;
height: 2px;
background: var(--color-text);
border-radius: 2px;
transition: all var(--transition);
}
.nav--open .nav-hamburger span:nth-child(1) {
transform: translateY(7px) rotate(45deg);
}
.nav--open .nav-hamburger span:nth-child(2) {
opacity: 0;
}
.nav--open .nav-hamburger span:nth-child(3) {
transform: translateY(-7px) rotate(-45deg);
}
.nav-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--space-4);
}
/* Nav links — centered column */
.nav-links {
display: flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
}
.nav-link {
background: none;
border: none;
cursor: pointer;
font-size: 0.8125rem;
font-weight: 500;
letter-spacing: 0.02em;
color: var(--color-text-muted);
padding: 0.375rem 0.625rem;
position: relative;
transition: color var(--transition);
white-space: nowrap;
}
/* Underline */
.nav-link::after {
content: '';
position: absolute;
bottom: -2px;
left: 0.625rem;
right: 0.625rem;
height: 1px;
background: rgba(255, 255, 255, 0.30);
transform: scaleX(1);
transition: background var(--transition);
}
.nav-link:hover {
color: var(--color-text);
}
.nav-link:hover::after {
background: var(--color-accent);
}
/* Mobile-only copies of links — hidden on desktop */
.nav-link--mob { display: none; }
.nav-mob-sep {
display: none;
height: 1px;
background: rgba(255, 255, 255, 0.10);
margin: 0.5rem 0;
}
/* Cursor toggle */
.cursor-toggle {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
}
.cursor-toggle__input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
pointer-events: none;
}
.cursor-toggle__label {
font-size: 0.7rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-text-muted);
transition: color var(--transition);
margin-right: 4px;
}
.cursor-toggle:hover .cursor-toggle__label {
color: var(--color-text);
}
.cursor-toggle__track {
position: relative;
width: 36px;
height: 20px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.15);
transition: background var(--transition), border-color var(--transition), box-shadow var(--transition);
flex-shrink: 0;
}
.cursor-toggle__track--on {
background: rgba(139, 61, 184, 0.45);
border-color: rgba(139, 61, 184, 0.7);
box-shadow: 0 0 8px rgba(139, 61, 184, 0.4);
}
.cursor-toggle__thumb {
position: absolute;
top: 3px;
left: 3px;
width: 12px;
height: 12px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.45);
transition: transform var(--transition), background var(--transition);
}
.cursor-toggle__track--on .cursor-toggle__thumb {
transform: translateX(16px);
background: #c084fc;
}
.nav-cta {
font-size: 0.8125rem;
padding: 0.625rem 1.375rem;
}
@media (pointer: coarse) {
.cursor-toggle {
display: none;
}
}
@media (max-width: 768px) {
.nav-links {
display: none;
}
}
@media (max-width: 640px) {
.nav-inner {
grid-template-columns: 1fr auto;
}
.nav-hamburger {
display: flex;
}
.nav-actions {
display: none;
position: absolute;
top: 68px;
left: 0;
right: 0;
background: rgba(11, 11, 11, 0.97);
backdrop-filter: blur(12px);
padding: 1rem var(--section-padding-x) 1.5rem;
border-top: 1px solid var(--color-border);
flex-direction: column;
align-items: stretch;
gap: 0.125rem;
}
.nav-actions--open {
display: flex;
}
.nav-link--mob {
display: block;
font-size: 0.9375rem;
padding: 0.65rem 0.25rem;
text-align: left;
}
.nav-link--mob::after {
display: none;
}
.nav-mob-sep {
display: block;
}
.nav-cta {
text-align: center;
justify-content: center;
margin-top: 0.25rem;
}
}

View File

@@ -0,0 +1,90 @@
import { useState, useEffect } from 'react'
import { Link, useLocation } from 'react-router-dom'
import './Navigation.css'
const Logo = () => (
<svg viewBox="0 0 180 36" xmlns="http://www.w3.org/2000/svg" className="nav-logo-svg" aria-label="Superfice">
<rect x="0" y="6" width="3" height="24" rx="1.5" fill="#8B3DB8" />
<text x="10" y="27" fontFamily="Inter, sans-serif" fontSize="20" fontWeight="700" letterSpacing="2.5" fill="#F0F0F0">SUPERFICE</text>
</svg>
)
export default function Navigation({ customCursor = true, onToggleCursor }) {
const [scrolled, setScrolled] = useState(false)
const [menuOpen, setMenuOpen] = useState(false)
const location = useLocation()
const isHome = location.pathname === '/'
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 40)
window.addEventListener('scroll', onScroll, { passive: true })
return () => window.removeEventListener('scroll', onScroll)
}, [])
useEffect(() => {
setMenuOpen(false)
}, [location])
const scrollTo = (id) => {
setMenuOpen(false)
if (!isHome) {
window.location.href = `/#${id}`
return
}
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth' })
}
return (
<nav className={`nav${scrolled ? ' nav--scrolled' : ''}${menuOpen ? ' nav--open' : ''}`}>
<div className="nav-inner">
<Link to="/" className="nav-logo">
<Logo />
</Link>
<button
className="nav-hamburger"
onClick={() => setMenuOpen(!menuOpen)}
aria-label="Menü"
aria-expanded={menuOpen}
>
<span /><span /><span />
</button>
<div className="nav-links" role="list">
<button className="nav-link" onClick={() => scrollTo('leistung')}>Leistungen</button>
<button className="nav-link" onClick={() => scrollTo('aufmass')}>Aufmaß</button>
<button className="nav-link" onClick={() => scrollTo('rechner')}>Preise</button>
<button className="nav-link" onClick={() => scrollTo('referenzen')}>Referenzen</button>
<button className="nav-link" onClick={() => scrollTo('kontakt')}>Kontakt</button>
</div>
<div className={`nav-actions${menuOpen ? ' nav-actions--open' : ''}`}>
<button className="nav-link nav-link--mob" onClick={() => scrollTo('leistung')}>Leistungen</button>
<button className="nav-link nav-link--mob" onClick={() => scrollTo('aufmass')}>Aufmaß</button>
<button className="nav-link nav-link--mob" onClick={() => scrollTo('rechner')}>Preise</button>
<button className="nav-link nav-link--mob" onClick={() => scrollTo('referenzen')}>Referenzen</button>
<button className="nav-link nav-link--mob" onClick={() => scrollTo('kontakt')}>Kontakt</button>
<span className="nav-mob-sep" aria-hidden="true" />
<label className="cursor-toggle" title={customCursor ? 'Custom cursor aktiv' : 'Standard cursor aktiv'}>
<span className="cursor-toggle__label">
{customCursor ? 'Custom' : 'Default'}
</span>
<span className={`cursor-toggle__track${customCursor ? ' cursor-toggle__track--on' : ''}`}>
<span className="cursor-toggle__thumb" />
</span>
<input
type="checkbox"
checked={customCursor}
onChange={onToggleCursor}
className="cursor-toggle__input"
aria-label="Custom cursor umschalten"
/>
</label>
<button className="btn btn--primary nav-cta" onClick={() => scrollTo('kontakt')}>
Termin vereinbaren
</button>
</div>
</div>
</nav>
)
}

View File

@@ -0,0 +1,30 @@
.painpoint {
padding: var(--section-padding-y) 0;
background: rgba(18, 18, 18, 0.68);
border-top: 1px solid var(--color-border);
border-bottom: 1px solid var(--color-border);
}
.painpoint .container--narrow {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-8);
text-align: center;
}
.painpoint-line {
width: 60px;
height: 2px;
background: var(--color-accent);
border-radius: 2px;
}
.painpoint-text {
font-size: clamp(1.5rem, 3.5vw, 2.5rem);
font-weight: 700;
line-height: 1.25;
letter-spacing: -0.02em;
color: var(--color-text);
max-width: 680px;
}

View File

@@ -0,0 +1,15 @@
import './PainPoint.css'
export default function PainPoint() {
return (
<div className="painpoint">
<div className="container--narrow">
<div className="painpoint-line" aria-hidden="true" />
<p className="painpoint-text">
Ein falsch gemessener Raum kostet Sie mehr als eine zweite Anfahrt.
</p>
<div className="painpoint-line" aria-hidden="true" />
</div>
</div>
)
}

View File

@@ -0,0 +1,77 @@
.refs {
background: var(--color-bg);
border-top: 1px solid var(--color-border);
}
.refs-header {
margin-bottom: var(--space-12);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.refs-label {
font-size: var(--font-size-xs);
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--color-accent);
font-weight: 600;
}
.refs-title {
font-size: clamp(1.75rem, 3vw, 2.5rem);
font-weight: 700;
letter-spacing: -0.02em;
color: var(--color-text);
}
.refs-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: var(--space-6);
max-width: 900px;
}
.ref-card {
padding: var(--space-8);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.ref-quote-mark {
font-size: 4rem;
line-height: 1;
color: var(--color-accent);
font-family: Georgia, serif;
margin-bottom: calc(var(--space-6) * -1);
}
.ref-quote {
font-size: var(--font-size-md);
color: var(--color-text);
line-height: 1.65;
font-style: italic;
}
.ref-author {
display: flex;
flex-direction: column;
gap: var(--space-1);
padding-top: var(--space-4);
border-top: 1px solid var(--color-border);
}
.ref-name {
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--color-text);
}
.ref-company {
font-size: var(--font-size-xs);
color: var(--color-text-muted);
}

View File

@@ -0,0 +1,40 @@
import './References.css'
// Hidden initially, shown when real testimonials are available
// Set SHOW_REFERENCES = true and populate TESTIMONIALS to activate
const SHOW_REFERENCES = false
const TESTIMONIALS = [
// {
// quote: "Das Aufmaß war innerhalb eines Tages fertig und direkt in unsere Planungssoftware importierbar. Keine Korrekturen nötig.",
// name: "Max Mustermann",
// company: "Schreinerei Mustermann GmbH",
// },
]
export default function References() {
if (!SHOW_REFERENCES || TESTIMONIALS.length === 0) return null
return (
<div className="refs section">
<div className="container">
<div className="refs-header">
<span className="refs-label">Referenzen</span>
<h2 className="refs-title">Was unsere Kunden sagen.</h2>
</div>
<div className="refs-grid">
{TESTIMONIALS.map((t, i) => (
<div key={i} className="ref-card">
<div className="ref-quote-mark" aria-hidden="true">"</div>
<p className="ref-quote">{t.quote}</p>
<div className="ref-author">
<span className="ref-name">{t.name}</span>
<span className="ref-company">{t.company}</span>
</div>
</div>
))}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,162 @@
.rv-overlay {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(6, 6, 10, 0.82);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
animation: rv-fade-in 0.22s ease;
}
@keyframes rv-fade-in {
from { opacity: 0 }
to { opacity: 1 }
}
.rv-modal {
width: 100%;
max-width: 640px;
height: min(580px, 90vh);
background: rgba(14, 14, 22, 0.96);
border: 1px solid rgba(139, 61, 184, 0.40);
border-radius: 16px;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow:
0 0 0 1px rgba(139, 61, 184, 0.12),
0 24px 80px rgba(0, 0, 0, 0.65),
0 0 60px rgba(139, 61, 184, 0.12);
animation: rv-slide-up 0.28s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes rv-slide-up {
from { opacity: 0; transform: translateY(24px) scale(0.97) }
to { opacity: 1; transform: translateY(0) scale(1) }
}
.rv-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 1.5rem 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
flex-shrink: 0;
}
.rv-title-group {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.rv-label {
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--color-accent);
}
.rv-title {
font-size: 1.1rem;
font-weight: 700;
color: var(--color-text);
letter-spacing: -0.01em;
}
.rv-close {
width: 32px;
height: 32px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.10);
color: var(--color-text-muted);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background var(--transition), color var(--transition), border-color var(--transition);
flex-shrink: 0;
}
.rv-close svg { width: 14px; height: 14px; }
.rv-close:hover {
background: rgba(139, 61, 184, 0.22);
border-color: rgba(139, 61, 184, 0.50);
color: var(--color-text);
}
.rv-canvas-wrap {
flex: 1;
position: relative;
overflow: hidden;
}
.rv-canvas {
width: 100%;
height: 100%;
display: block;
cursor: grab;
opacity: 0;
transition: opacity 0.35s ease;
}
.rv-canvas--ready { opacity: 1; }
.rv-canvas:active { cursor: grabbing; }
.rv-loader {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
pointer-events: none;
}
.rv-loader-ring {
width: 40px;
height: 40px;
border-radius: 50%;
border: 2px solid rgba(139, 61, 184, 0.18);
border-top-color: var(--color-accent);
animation: rv-spin 0.85s linear infinite;
}
@keyframes rv-spin { to { transform: rotate(360deg); } }
.rv-loader-text {
font-size: 0.72rem;
letter-spacing: 0.10em;
text-transform: uppercase;
color: var(--color-text-faint);
animation: rv-pulse 1.6s ease-in-out infinite;
}
@keyframes rv-pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 0.9; }
}
.rv-footer {
display: flex;
align-items: center;
justify-content: center;
gap: 1.5rem;
padding: 0.75rem 1.5rem;
border-top: 1px solid rgba(255, 255, 255, 0.07);
flex-shrink: 0;
}
.rv-hint {
font-size: 0.72rem;
color: var(--color-text-faint);
letter-spacing: 0.03em;
}

View File

@@ -0,0 +1,628 @@
import { useEffect, useRef, useState } from 'react'
import './RoomViewer.css'
// =================================================================
// LIVING ROOM GEOMETRY
// =================================================================
function buildRoom() {
const V = [], E = []
function ring(cx, y, cz, rx, rz, n = 24) {
const b = V.length
for (let i = 0; i < n; i++) {
const a = (i / n) * Math.PI * 2
V.push([cx + rx * Math.cos(a), y, cz + rz * Math.sin(a)])
}
for (let i = 0; i < n; i++) E.push([b + i, b + (i + 1) % n])
return b
}
function connectRings(b1, b2, n) {
for (let i = 0; i < n; i++) E.push([b1 + i, b2 + i])
}
function box(x1, y1, z1, x2, y2, z2) {
const b = V.length
V.push(
[x1,y1,z1],[x2,y1,z1],[x2,y1,z2],[x1,y1,z2],
[x1,y2,z1],[x2,y2,z1],[x2,y2,z2],[x1,y2,z2],
)
E.push(
[b,b+1],[b+1,b+2],[b+2,b+3],[b+3,b],
[b+4,b+5],[b+5,b+6],[b+6,b+7],[b+7,b+4],
[b,b+4],[b+1,b+5],[b+2,b+6],[b+3,b+7],
)
}
function line(x1,y1,z1, x2,y2,z2) {
const b = V.length
V.push([x1,y1,z1],[x2,y2,z2])
E.push([b,b+1])
}
function cylinder(cx, y1, cz, rx, rz, y2, n = 16) {
const b1 = ring(cx, y1, cz, rx, rz, n)
const b2 = ring(cx, y2, cz, rx, rz, n)
connectRings(b1, b2, n)
}
// ── ROOM ──────────────────────────────────────────────────────
const RX = 2.10, RZ = 2.80, FL = -0.50, CL = 1.38
const rc = V.length
V.push(
[-RX,FL,-RZ],[RX,FL,-RZ],[RX,FL, RZ],[-RX,FL, RZ],
[-RX,CL,-RZ],[RX,CL,-RZ],[RX,CL, RZ],[-RX,CL, RZ],
)
E.push(
[rc,rc+1],[rc+1,rc+2],[rc+2,rc+3],[rc+3,rc],
[rc+4,rc+5],[rc+5,rc+6],[rc+6,rc+7],[rc+7,rc+4],
[rc,rc+4],[rc+1,rc+5],[rc+2,rc+6],[rc+3,rc+7],
)
// ── DOOR (front wall z=-RZ, next to small window) ─────────────
{
const d = V.length
V.push([-0.50,FL,-RZ],[-0.50,FL+1.80,-RZ],[0.28,FL,-RZ],[0.28,FL+1.80,-RZ])
E.push([d,d+1],[d+2,d+3],[d+1,d+3])
}
// Door panel open inward ~35°
{
const hw = 0.78, hh = 1.80, ang = 0.62
const fx = -0.50 + hw * Math.cos(ang)
const fz = -RZ + hw * Math.sin(ang)
const d = V.length
V.push([-0.50,FL,-RZ],[-0.50,FL+hh,-RZ],[fx,FL,fz],[fx,FL+hh,fz])
E.push([d,d+1],[d+2,d+3],[d,d+2],[d+1,d+3])
// Mid-panel line
line(-0.50, FL+0.90, -RZ, fx, FL+0.90, fz)
// Hinges
for (const hy of [FL+0.08, FL+0.90, FL+1.68]) {
box(-0.52, hy, -RZ, -0.48, hy+0.06, -RZ+0.04)
}
// Handle
const hpx = -0.50 + 0.58*hw*Math.cos(ang)
const hpz = -RZ + 0.58*hw*Math.sin(ang)
box(hpx-0.025, FL+0.90, hpz-0.025, hpx+0.025, FL+1.00, hpz+0.025)
line(hpx, FL+1.00, hpz, hpx+0.05, FL+0.94, hpz-0.04)
}
// ── LARGE WINDOW (right wall x=RX) ────────────────────────────
{
const w = V.length
V.push([RX,0.52,0.60],[RX,0.52,2.10],[RX,CL-0.10,0.60],[RX,CL-0.10,2.10])
E.push([w,w+1],[w+2,w+3],[w,w+2],[w+1,w+3])
// Vertical center bar
V.push([RX,0.52,1.35],[RX,CL-0.10,1.35])
E.push([w+4,w+5])
// Horizontal mid bar
V.push([RX,0.90,0.60],[RX,0.90,2.10])
E.push([w+6,w+7])
// Sill
box(RX-0.08, 0.48, 0.56, RX, 0.52, 2.14)
}
// Small window (front wall, right side)
{
const w = V.length
V.push([0.40,0.68,-RZ],[1.40,0.68,-RZ],[0.40,1.18,-RZ],[1.40,1.18,-RZ])
E.push([w,w+1],[w+2,w+3],[w,w+2],[w+1,w+3])
V.push([0.90,0.68,-RZ],[0.90,1.18,-RZ])
E.push([w+4,w+5])
box(0.36,0.64,-RZ, 1.44,0.68,-RZ+0.06)
}
// ── CEILING PENDANT (large, above center) ─────────────────────
ring(0.0, CL, -0.10, 0.32, 0.32, 24)
ring(0.0, CL, -0.10, 0.12, 0.12, 16)
line(0.0, CL, -0.10, 0.0, CL-0.28, -0.10)
ring(0.0, CL-0.28, -0.10, 0.28, 0.28, 20)
ring(0.0, CL-0.38, -0.10, 0.20, 0.20, 16)
// ── TV MEDIA CONSOLE (back wall z=RZ) ─────────────────────────
box(-0.82, FL, 2.56, 0.82, FL+0.46, 2.80)
// Console door dividers
line(-0.28, FL+0.04, 2.56, -0.28, FL+0.42, 2.56)
line( 0.28, FL+0.04, 2.56, 0.28, FL+0.42, 2.56)
// Console handles
box(-0.58, FL+0.20, 2.55, -0.38, FL+0.24, 2.56)
box( 0.38, FL+0.20, 2.55, 0.58, FL+0.24, 2.56)
// TV screen (wall-mounted above console)
box(-0.72, FL+0.54, 2.76, 0.72, FL+1.14, 2.80)
// Screen inner frame
box(-0.68, FL+0.58, 2.77, 0.68, FL+1.10, 2.79)
// TV legs / mount
line(-0.18, FL+0.54, 2.76, -0.18, FL+0.46, 2.76)
line( 0.18, FL+0.54, 2.76, 0.18, FL+0.46, 2.76)
// Soundbar below screen
box(-0.52, FL+0.47, 2.75, 0.52, FL+0.52, 2.78)
// ── SOFA (3-seat, facing TV) ───────────────────────────────────
const SX1 = -0.96, SX2 = 0.96, SZ1 = 0.70, SZ2 = 1.38
// Seat base
box(SX1, FL, SZ1, SX2, FL+0.42, SZ2)
// Backrest at SZ1 side (away from TV — correct orientation)
box(SX1, FL+0.42, SZ1, SX2, FL+0.86, SZ1+0.18)
// Left armrest
box(SX1, FL, SZ1, SX1+0.14, FL+0.54, SZ2)
// Right armrest
box(SX2-0.14, FL, SZ1, SX2, FL+0.54, SZ2)
// Cushion dividers (seat area from SZ1+0.18 to SZ2)
line(SX1+0.14 + (SX2-SX1-0.28)*0.333, FL+0.42, SZ1+0.18,
SX1+0.14 + (SX2-SX1-0.28)*0.333, FL+0.42, SZ2)
line(SX1+0.14 + (SX2-SX1-0.28)*0.667, FL+0.42, SZ1+0.18,
SX1+0.14 + (SX2-SX1-0.28)*0.667, FL+0.42, SZ2)
// Sofa legs (4)
for (const [lx, lz] of [[SX1+0.10, SZ1+0.10],[SX2-0.10, SZ1+0.10],[SX1+0.10, SZ2-0.10],[SX2-0.10, SZ2-0.10]]) {
box(lx-0.04, FL-0.06, lz-0.04, lx+0.04, FL, lz+0.04)
}
// Back cushion dividers
line(SX1+0.14 + (SX2-SX1-0.28)*0.333, FL+0.42, SZ1,
SX1+0.14 + (SX2-SX1-0.28)*0.333, FL+0.86, SZ1+0.18)
line(SX1+0.14 + (SX2-SX1-0.28)*0.667, FL+0.42, SZ1,
SX1+0.14 + (SX2-SX1-0.28)*0.667, FL+0.86, SZ1+0.18)
// Throw pillow on sofa (left side, leaning against backrest)
box(SX1+0.18, FL+0.42, SZ1+0.18, SX1+0.42, FL+0.62, SZ1+0.42)
// ── COFFEE TABLE (between sofa and TV) ────────────────────────
box(-0.44, FL+0.40, 1.54, 0.44, FL+0.44, 2.12)
// Lower shelf
box(-0.40, FL+0.18, 1.58, 0.40, FL+0.22, 2.08)
// 4 legs
for (const [lx, lz] of [[-0.38,1.58],[0.38,1.58],[-0.38,2.08],[0.38,2.08]]) {
cylinder(lx, FL, lz, 0.022, 0.022, FL+0.40, 8)
}
// Item on table (book/remote)
box(-0.14, FL+0.44, 1.70, 0.14, FL+0.47, 1.86)
// ── ARMCHAIR (right, facing TV) ───────────────────────────────
box(1.18, FL, -0.44, 1.78, FL+0.40, 0.20)
// Backrest at low-z side (away from TV)
box(1.18, FL+0.40, -0.44, 1.78, FL+0.76, -0.30)
box(1.18, FL, -0.44, 1.32, FL+0.52, 0.20)
box(1.64, FL, -0.44, 1.78, FL+0.52, 0.20)
// Armchair legs
for (const [lx, lz] of [[1.24,-0.38],[1.72,-0.38],[1.24,0.14],[1.72,0.14]]) {
box(lx-0.04, FL-0.06, lz-0.04, lx+0.04, FL, lz+0.04)
}
// Side table next to armchair
box(1.86, FL, -0.30, 2.06, FL+0.55, 0.06)
// Lamp on side table
cylinder(1.96, FL+0.55, -0.12, 0.018, 0.018, FL+0.82, 8)
ring(1.96, FL+0.82, -0.12, 0.14, 0.14, 14)
ring(1.96, FL+0.70, -0.12, 0.09, 0.09, 12)
// ── FLOOR LAMP (left of sofa) ──────────────────────────────────
cylinder(-1.30, FL, 0.40, 0.022, 0.022, 1.24, 8)
ring(-1.30, FL, 0.40, 0.14, 0.14, 14) // base
ring(-1.30, 1.24, 0.40, 0.06, 0.06, 10) // shade top
ring(-1.30, 1.08, 0.40, 0.22, 0.22, 18) // shade bottom
{
const st = ring(-1.30, 1.24, 0.40, 0.06, 0.06, 10)
const sb = ring(-1.30, 1.08, 0.40, 0.22, 0.22, 18)
// connect shade rings with 10 verticals
for (let i = 0; i < 10; i++) E.push([st + i, sb + Math.round(i * 18 / 10)])
}
// ── BOOKSHELF (left wall) ──────────────────────────────────────
box(-RX, FL, 1.20, -RX+0.36, CL-0.08, 2.10)
// 4 shelves
for (let i = 0; i < 4; i++) {
const sy = FL + 0.26 + i * 0.30
box(-RX, sy, 1.22, -RX+0.34, sy+0.03, 2.08)
}
// Vertical center divider
line(-RX+0.18, FL, 1.22, -RX+0.18, CL-0.08, 1.22)
// Books suggestion (small boxes on shelves)
for (let i = 0; i < 3; i++) {
const sy = FL + 0.29 + i * 0.30
box(-RX+0.02, sy, 1.26, -RX+0.10, sy+0.22, 1.50)
box(-RX+0.12, sy, 1.26, -RX+0.16, sy+0.18, 1.50)
}
// ── WARDROBE (left wall, front area) ──────────────────────────
{
const WX1 = -RX, WX2 = -RX + 0.58
const WZ1 = -2.72, WZ2 = 0.50
const WH = CL - 0.03
// Main body
box(WX1, FL, WZ1, WX2, WH, WZ2)
// Top cornice strip
box(WX1, WH, WZ1 - 0.01, WX2 + 0.01, WH + 0.03, WZ2 + 0.01)
// 4 door panels — vertical dividers on front face (x=WX2)
const seg = (WZ2 - WZ1) / 4
for (let i = 1; i < 4; i++) {
const dz = WZ1 + seg * i
line(WX2, FL + 0.04, dz, WX2, WH - 0.04, dz)
}
// Door handles (one per panel, at mid height ~0.90m)
for (let i = 0; i < 4; i++) {
const hz = WZ1 + seg * i + seg * 0.5
box(WX2, 0.84, hz - 0.03, WX2 + 0.02, 0.96, hz - 0.01)
}
// Horizontal groove line across all doors (upper third)
line(WX2, WH * 0.68, WZ1, WX2, WH * 0.68, WZ2)
// Baseboard / kick plate
box(WX1, FL, WZ1, WX2, FL + 0.07, WZ2)
}
// ── LARGE PICTURE — Chinese cherry blossom tree ───────────────
{
const ins = 0.05
const z1 = -2.30, z2 = -0.40, y1 = 0.38, y2 = 1.20
// Outer frame
const f = V.length
V.push([RX,y1,z1],[RX,y1,z2],[RX,y2,z2],[RX,y2,z1])
E.push([f,f+1],[f+1,f+2],[f+2,f+3],[f+3,f])
// Inner canvas border
const g = V.length
V.push([RX,y1+ins,z1+ins],[RX,y1+ins,z2-ins],[RX,y2-ins,z2-ins],[RX,y2-ins,z1+ins])
E.push([g,g+1],[g+1,g+2],[g+2,g+3],[g+3,g])
// Frame corner connectors
E.push([f,g],[f+1,g+1],[f+2,g+2],[f+3,g+3])
// Hanging wire
const cz = (z1 + z2) / 2
line(RX, y2, cz, RX, y2+0.05, cz)
// ── Cherry blossom tree — fills canvas ──────────────────────
const tx = RX, tz = cz, ty = y1 + ins
// canvas bounds: z in [z1+ins, z2-ins] = [-2.25, -0.45], y in [y1+ins, y2-ins] = [0.43, 1.15]
// Blossom: ring in YZ plane
const blossom = (bz, by, r = 0.065, n = 10) => {
const b = V.length
for (let i = 0; i < n; i++) {
const a = (i / n) * Math.PI * 2
V.push([tx, by + r * Math.cos(a), bz + r * Math.sin(a)])
}
for (let i = 0; i < n; i++) E.push([b + i, b + (i + 1) % n])
}
// Trunk — two parallel lines for thickness, gentle S-curve
const trunk = [
[tz+0.02, ty+0.00], [tz+0.02, ty+0.10],
[tz+0.00, ty+0.22], [tz-0.02, ty+0.34],
[tz-0.03, ty+0.46], [tz-0.04, ty+0.58],
]
for (let k = 0; k < 2; k++) {
const off = k * 0.018
for (let i = 0; i < trunk.length - 1; i++)
line(tx, trunk[i][1], trunk[i][0]+off, tx, trunk[i+1][1], trunk[i+1][0]+off)
}
// Root flare
line(tx, ty+0.00, tz+0.02, tx, ty+0.09, tz+0.20)
line(tx, ty+0.00, tz+0.02, tx, ty+0.09, tz-0.16)
line(tx, ty+0.00, tz+0.02, tx, ty+0.06, tz+0.00)
// ── LEFT branch system — reaches toward z1 edge ──
line(tx, ty+0.20, tz+0.00, tx, ty+0.34, tz-0.30) // L1
line(tx, ty+0.34, tz-0.30, tx, ty+0.46, tz-0.58) // L2
line(tx, ty+0.46, tz-0.58, tx, ty+0.54, tz-0.78) // L3 → near left edge
line(tx, ty+0.46, tz-0.58, tx, ty+0.56, tz-0.66) // L3b sub
line(tx, ty+0.34, tz-0.30, tx, ty+0.48, tz-0.42) // L2b sub
line(tx, ty+0.48, tz-0.42, tx, ty+0.58, tz-0.50) // L2b tip
line(tx, ty+0.26, tz-0.14, tx, ty+0.40, tz-0.22) // L1 sub low
// ── RIGHT branch system — reaches toward z2 edge ──
line(tx, ty+0.28, tz+0.02, tx, ty+0.42, tz+0.32) // R1
line(tx, ty+0.42, tz+0.32, tx, ty+0.52, tz+0.60) // R2
line(tx, ty+0.52, tz+0.60, tx, ty+0.58, tz+0.78) // R3 → near right edge
line(tx, ty+0.52, tz+0.60, tx, ty+0.60, tz+0.68) // R3b sub
line(tx, ty+0.42, tz+0.32, tx, ty+0.54, tz+0.44) // R2b sub
line(tx, ty+0.54, tz+0.44, tx, ty+0.62, tz+0.52) // R2b tip
line(tx, ty+0.30, tz+0.16, tx, ty+0.44, tz+0.24) // R1 sub low
// ── TOP branch system — reaches toward y2 edge ──
line(tx, ty+0.58, tz-0.04, tx, ty+0.64, tz-0.20) // T1 left
line(tx, ty+0.64, tz-0.20, tx, ty+0.68, tz-0.32) // T1 tip
line(tx, ty+0.58, tz-0.04, tx, ty+0.66, tz+0.12) // T2 right
line(tx, ty+0.66, tz+0.12, tx, ty+0.70, tz+0.22) // T2 tip
line(tx, ty+0.58, tz-0.04, tx, ty+0.70, tz-0.04) // T3 straight up
line(tx, ty+0.64, tz-0.20, tx, ty+0.70, tz-0.12) // T sub
// ── Blossom clusters — all within canvas bounds ──
// Far left
blossom(tz-0.80, ty+0.56, 0.062)
blossom(tz-0.80, ty+0.56, 0.030, 6)
blossom(tz-0.68, ty+0.60, 0.052)
// Left mid
blossom(tz-0.52, ty+0.60, 0.050)
blossom(tz-0.44, ty+0.65, 0.044)
blossom(tz-0.24, ty+0.51, 0.042)
// Far right
blossom(tz+0.78, ty+0.60, 0.058)
blossom(tz+0.78, ty+0.60, 0.030, 6)
blossom(tz+0.66, ty+0.64, 0.050)
// Right mid
blossom(tz+0.50, ty+0.64, 0.048)
blossom(tz+0.42, ty+0.66, 0.042)
blossom(tz+0.26, ty+0.53, 0.042)
// Top — y_max = ty+0.70+r must be ≤ 1.15
blossom(tz-0.33, ty+0.68, 0.054) // y_max=1.13 ✓
blossom(tz-0.33, ty+0.68, 0.028, 6)
blossom(tz+0.22, ty+0.68, 0.050)
blossom(tz+0.22, ty+0.68, 0.028, 6)
blossom(tz-0.05, ty+0.68, 0.044)
blossom(tz-0.14, ty+0.62, 0.038)
}
// ── RADIATOR under large window ────────────────────────────────
{
const RY1 = FL+0.08, RY2 = FL+0.52
const RZa = 0.64, RZb = 2.06
const RXf = RX - 0.10
line(RXf, RY1, RZa, RXf, RY2, RZa)
line(RXf, RY1, RZb, RXf, RY2, RZb)
for (let i = 0; i <= 8; i++) {
const y = RY1 + (RY2 - RY1) * i / 8
line(RXf, y, RZa, RXf, y, RZb)
}
line(RX, RY1+0.10, RZa, RXf, RY1+0.10, RZa)
line(RX, RY1+0.10, RZb, RXf, RY1+0.10, RZb)
line(RX, RY2-0.10, RZa, RXf, RY2-0.10, RZa)
line(RX, RY2-0.10, RZb, RXf, RY2-0.10, RZb)
}
// ── FLOOR PLANT (back-left corner) ────────────────────────────
cylinder(-1.82, FL, 2.48, 0.12, 0.12, FL+0.24, 12)
ring(-1.82, FL+0.24, 2.48, 0.14, 0.14, 12) // pot rim
// Plant leaves (several arching rings going up)
for (let i = 0; i < 5; i++) {
const a = (i / 5) * Math.PI * 2
const lx = -1.82 + 0.18 * Math.cos(a)
const lz = 2.48 + 0.18 * Math.sin(a)
line(-1.82, FL+0.28, 2.48, lx, FL+0.80 + i*0.06, lz)
}
// ── CARPET / RUG (between sofa and TV) ────────────────────────
ring(0.10, FL+0.01, 1.40, 1.32, 0.96, 44)
ring(0.10, FL+0.01, 1.40, 1.14, 0.80, 44)
return { verts: V, edges: E }
}
const { verts, edges } = buildRoom()
// =================================================================
// ENGINE
// =================================================================
export default function RoomViewer({ onClose }) {
const canvasRef = useRef(null)
const [ready, setReady] = useState(false)
const stateRef = useRef({
rotX: 0.30, rotY: -0.45,
dragging: false, lastX: 0, lastY: 0,
autoRotate: true, idleTimer: null,
zoom: 0.64, rafId: null, lastTs: 0,
})
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
const state = stateRef.current
const resize = () => {
const dpr = Math.min(window.devicePixelRatio || 1, 2)
canvas.width = canvas.offsetWidth * dpr
canvas.height = canvas.offsetHeight * dpr
}
resize()
const ro = new ResizeObserver(resize)
ro.observe(canvas)
function project(v) {
let [x, y, z] = v
const { rotX, rotY, zoom } = state
const dpr = Math.min(window.devicePixelRatio || 1, 2)
const W = canvas.width / dpr
const H = canvas.height / dpr
const cy_ = Math.cos(rotY), sy_ = Math.sin(rotY)
const x1 = x * cy_ - z * sy_
const z1 = x * sy_ + z * cy_
const cx_ = Math.cos(rotX), sx_ = Math.sin(rotX)
const y2 = y * cx_ - z1 * sx_
const z2 = y * sx_ + z1 * cx_
const fov = Math.min(W, H) * 1.75
const scale = Math.min(W, H) * 0.095 * zoom
const d = fov + z2
return {
x: (x1 / d) * fov * scale + W / 2,
y: (-y2 / d) * fov * scale + H / 2,
z: z2,
}
}
function render() {
const dpr = Math.min(window.devicePixelRatio || 1, 2)
const W = canvas.width / dpr
const H = canvas.height / dpr
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, W, H)
if (verts.length === 0) return
const proj = verts.map(v => project(v))
let zMin = Infinity, zMax = -Infinity
for (const p of proj) {
if (p.z < zMin) zMin = p.z
if (p.z > zMax) zMax = p.z
}
const zRange = zMax - zMin || 1
const sorted = edges
.map(([a, b]) => ({ a, b, depth: (proj[a].z + proj[b].z) * 0.5 }))
.sort((e1, e2) => e2.depth - e1.depth)
for (const { a, b, depth } of sorted) {
const pa = proj[a]
const pb = proj[b]
const t = (depth - zMin) / zRange
const da = Math.max(0.10, Math.min(1.0, 1.05 - t * 0.88))
ctx.beginPath()
ctx.moveTo(pa.x, pa.y)
ctx.lineTo(pb.x, pb.y)
ctx.strokeStyle = `rgba(190,110,255,${da})`
ctx.lineWidth = 1.3
ctx.stroke()
}
for (const p of proj) {
const t = (p.z - zMin) / zRange
const da = Math.max(0.08, 0.45 - t * 0.35)
ctx.beginPath()
ctx.arc(p.x, p.y, 1.4, 0, Math.PI * 2)
ctx.fillStyle = `rgba(220,170,255,${da})`
ctx.fill()
}
}
let firstFrame = true
function loop(ts) {
state.rafId = requestAnimationFrame(loop)
if (ts - state.lastTs < 32) return
state.lastTs = ts
if (state.autoRotate && !state.dragging) state.rotY += 0.006
render()
if (firstFrame) { firstFrame = false; setReady(true) }
}
state.rafId = requestAnimationFrame(loop)
const stopAutoRotate = () => {
state.autoRotate = false
clearTimeout(state.idleTimer)
state.idleTimer = setTimeout(() => { state.autoRotate = true }, 2200)
}
const onMouseDown = (e) => {
state.dragging = true
state.lastX = e.clientX
state.lastY = e.clientY
stopAutoRotate()
}
const onMouseMove = (e) => {
if (!state.dragging) return
state.rotY += (e.clientX - state.lastX) * 0.008
state.rotX += (e.clientY - state.lastY) * 0.008
state.rotX = Math.max(-0.75, Math.min(0.75, state.rotX))
state.lastX = e.clientX
state.lastY = e.clientY
}
const onMouseUp = () => { state.dragging = false }
const onWheel = (e) => {
e.preventDefault()
state.zoom *= e.deltaY > 0 ? 0.93 : 1.07
state.zoom = Math.max(0.35, Math.min(3.0, state.zoom))
stopAutoRotate()
}
let lastTX = 0, lastTY = 0, lastPinchDist = 0
const onTouchStart = (e) => {
stopAutoRotate()
state.dragging = true
if (e.touches.length === 1) {
lastTX = e.touches[0].clientX
lastTY = e.touches[0].clientY
} else if (e.touches.length === 2) {
lastPinchDist = Math.hypot(
e.touches[0].clientX - e.touches[1].clientX,
e.touches[0].clientY - e.touches[1].clientY,
)
}
}
const onTouchMove = (e) => {
if (!state.dragging) return
if (e.touches.length === 1) {
state.rotY += (e.touches[0].clientX - lastTX) * 0.008
state.rotX += (e.touches[0].clientY - lastTY) * 0.008
state.rotX = Math.max(-0.75, Math.min(0.75, state.rotX))
lastTX = e.touches[0].clientX
lastTY = e.touches[0].clientY
} else if (e.touches.length === 2) {
const dist = Math.hypot(
e.touches[0].clientX - e.touches[1].clientX,
e.touches[0].clientY - e.touches[1].clientY,
)
if (lastPinchDist) state.zoom *= dist / lastPinchDist
state.zoom = Math.max(0.35, Math.min(3.0, state.zoom))
lastPinchDist = dist
}
}
const onTouchEnd = () => { state.dragging = false; lastPinchDist = 0 }
canvas.addEventListener('mousedown', onMouseDown)
window.addEventListener('mousemove', onMouseMove)
window.addEventListener('mouseup', onMouseUp)
canvas.addEventListener('wheel', onWheel, { passive: false })
canvas.addEventListener('touchstart', onTouchStart, { passive: true })
canvas.addEventListener('touchmove', onTouchMove, { passive: true })
canvas.addEventListener('touchend', onTouchEnd)
const onKeyDown = (e) => { if (e.key === 'Escape') onClose() }
window.addEventListener('keydown', onKeyDown)
return () => {
cancelAnimationFrame(state.rafId)
clearTimeout(state.idleTimer)
ro.disconnect()
setReady(false)
canvas.removeEventListener('mousedown', onMouseDown)
window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('mouseup', onMouseUp)
canvas.removeEventListener('wheel', onWheel)
canvas.removeEventListener('touchstart', onTouchStart)
canvas.removeEventListener('touchmove', onTouchMove)
canvas.removeEventListener('touchend', onTouchEnd)
window.removeEventListener('keydown', onKeyDown)
}
}, [onClose])
return (
<div className="rv-overlay" onClick={onClose}>
<div className="rv-modal" onClick={e => e.stopPropagation()}>
<div className="rv-header">
<div className="rv-title-group">
<span className="rv-label">3D Modell</span>
<h3 className="rv-title">Raum / Grundriss</h3>
</div>
<button className="rv-close" onClick={onClose} aria-label="Schließen">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
</svg>
</button>
</div>
<div className="rv-canvas-wrap">
<canvas ref={canvasRef} className={`rv-canvas${ready ? ' rv-canvas--ready' : ''}`} />
{!ready && (
<div className="rv-loader" aria-label="Wird geladen">
<div className="rv-loader-ring" />
<span className="rv-loader-text">Modell wird geladen</span>
</div>
)}
</div>
<div className="rv-footer">
<span className="rv-hint">Ziehen zum Drehen</span>
<span className="rv-hint">Scrollen zum Zoomen</span>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,80 @@
.services {
background: rgba(13, 13, 13, 0.55);
}
.services-header {
margin-bottom: var(--space-12);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.services-label {
font-size: var(--font-size-xs);
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--color-accent);
font-weight: 600;
}
.services-title {
font-size: clamp(1.75rem, 3vw, 2.5rem);
font-weight: 700;
letter-spacing: -0.02em;
color: var(--color-text);
}
.services-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-6);
}
.service-card {
padding: var(--space-8);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
transition: border-color var(--transition);
}
.service-card:hover {
border-color: var(--color-accent-border);
}
.service-number {
font-size: var(--font-size-xs);
font-weight: 700;
letter-spacing: 0.1em;
color: var(--color-accent);
font-variant-numeric: tabular-nums;
}
.service-divider {
width: 32px;
height: 2px;
background: var(--color-accent-border);
border-radius: 2px;
margin: var(--space-5) 0;
}
.service-title {
font-size: var(--font-size-lg);
font-weight: 600;
color: var(--color-text);
margin-bottom: var(--space-4);
line-height: 1.3;
}
.service-body {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
line-height: 1.7;
}
@media (max-width: 900px) {
.services-grid {
grid-template-columns: 1fr;
max-width: 520px;
}
}

View File

@@ -0,0 +1,42 @@
import './Services.css'
const steps = [
{
number: '01',
title: 'Aufmaß vor Ort',
body: 'Unser Techniker kommt zu Ihnen. Auch möblierte Räume, Altbau, Dachschrägen und komplexe Geometrien alles in einem Termin erfasst.',
},
{
number: '02',
title: 'Datenaufbereitung',
body: 'Ihre Messdaten werden geprüft, bereinigt und in das Format überführt, das Sie brauchen. Keine Nachbearbeitung auf Ihrer Seite.',
},
{
number: '03',
title: 'Fertige Planungsgrundlage',
body: 'Sie erhalten ein vollständiges 3D-Aufmaß als CAD-Datei. Direkt nutzbar in Ihrer Software, ohne Umwege.',
},
]
export default function Services() {
return (
<div className="services section">
<div className="container">
<div className="services-header">
<span className="services-label">Leistung</span>
<h2 className="services-title">Vom Termin zur Datei.</h2>
</div>
<div className="services-grid">
{steps.map((step) => (
<div key={step.number} className="service-card">
<div className="service-number">{step.number}</div>
<div className="service-divider" />
<h3 className="service-title">{step.title}</h3>
<p className="service-body">{step.body}</p>
</div>
))}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,10 @@
.space-bg {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 0;
display: block;
pointer-events: none;
}

View File

@@ -0,0 +1,415 @@
/**
* SpaceBackground
* Adapted from Experiment_backup/scripts/hex-background.js
* Recoloured to Superfice purple palette (#8B3DB8).
* Effects: layered star field · nebula clouds · shooting stars
* · mouse-reactive glass shards · diffraction spikes · cursor aura
*/
import { useEffect, useRef } from 'react'
import './SpaceBackground.css'
// ── Nebula clouds ─────────────────────────────────────────────────
const NEBULAS = [
// Purple core — brand dominant
{ px: 0.18, py: 0.25, pr: 0.62, cr: 139, cg: 61, cb: 184, a: 0.10, spx: 0.00014, spy: 0.00007, phase: 0.0 },
{ px: 0.78, py: 0.55, pr: 0.58, cr: 100, cg: 30, cb: 175, a: 0.08, spx: -0.00009, spy: 0.00011, phase: 2.1 },
// Blue-violet cloud
{ px: 0.50, py: 0.80, pr: 0.50, cr: 60, cg: 90, cb: 220, a: 0.07, spx: 0.00007, spy: -0.00009, phase: 4.3 },
{ px: 0.65, py: 0.12, pr: 0.44, cr: 80, cg: 50, cb: 200, a: 0.06, spx: -0.00010, spy: 0.00005, phase: 1.5 },
// Soft rose/magenta accent
{ px: 0.88, py: 0.35, pr: 0.32, cr: 220, cg: 70, cb: 160, a: 0.05, spx: -0.00006, spy: 0.00008, phase: 3.2 },
// Bright lavender centre
{ px: 0.32, py: 0.60, pr: 0.38, cr: 200, cg: 120, cb: 255, a: 0.06, spx: 0.00005, spy: -0.00006, phase: 3.7 },
// Deep teal hint
{ px: 0.60, py: 0.40, pr: 0.30, cr: 40, cg: 140, cb: 200, a: 0.04, spx: 0.00008, spy: 0.00010, phase: 5.2 },
]
// ── Shard debris ──────────────────────────────────────────────────
const SHARD_COLORS = [
{ r: 160, g: 75, b: 210 },
{ r: 185, g: 100, b: 255 },
{ r: 95, g: 30, b: 165 },
{ r: 220, g: 200, b: 255 },
{ r: 215, g: 130, b: 255 },
{ r: 120, g: 60, b: 200 },
{ r: 60, g: 100, b: 220 },
]
const SHARD_COUNT = 26
const SHARD_RADIUS = 230
const SCATTER_FORCE = 3.0
const ROT_V_MAX = 0.018
// Star layer definitions — depth from back (0) to front (2)
const STAR_LAYERS = [
{ count: 180, rMin: 0.12, rMax: 0.45, speed: 0.008, alphaMax: 0.38 }, // dust
{ count: 110, rMin: 0.40, rMax: 0.95, speed: 0.030, alphaMax: 0.70 }, // field
{ count: 45, rMin: 0.85, rMax: 2.20, speed: 0.055, alphaMax: 0.95 }, // bright
]
// Star colour palette
const STAR_PALETTES = [
(a) => `rgba(190,215,255,${a})`, // blue-white (hot)
(a) => `rgba(255,255,255,${a})`, // pure white
(a) => `rgba(255,248,210,${a})`, // warm yellow-white
(a) => `rgba(210,140,255,${a})`, // brand purple
(a) => `rgba(235,185,255,${a})`, // lavender
]
// ── Helpers ──────────────────────────────────────────────────────
function buildVerts(size, sides) {
const verts = []
const base = Math.random() * Math.PI * 2
for (let i = 0; i < sides; i++) {
const a = base + (Math.PI * 2 * i / sides) + (Math.random() - 0.5) * 0.55
const r = size * (0.50 + Math.random() * 0.50)
verts.push([Math.cos(a) * r, Math.sin(a) * r])
}
return verts
}
// ── Component ────────────────────────────────────────────────────
export default function SpaceBackground() {
const canvasRef = useRef(null)
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const isMobile = window.innerWidth <= 768 || ('ontouchstart' in window)
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
// ── Mobile / reduced-motion: one-shot static render ─────────
if (isMobile || prefersReducedMotion) {
const dpr = Math.min(window.devicePixelRatio || 1, 2)
const w = window.innerWidth
const h = window.innerHeight
canvas.width = w * dpr
canvas.height = h * dpr
canvas.style.width = `${w}px`
canvas.style.height = `${h}px`
const mc = canvas.getContext('2d')
mc.scale(dpr, dpr)
mc.fillStyle = '#0A0A12'
mc.fillRect(0, 0, w, h)
NEBULAS.slice(0, 4).forEach(n => {
const g = mc.createRadialGradient(n.px * w, n.py * h, 0, n.px * w, n.py * h, n.pr * Math.min(w, h))
g.addColorStop(0, `rgba(${n.cr},${n.cg},${n.cb},${n.a * 1.2})`)
g.addColorStop(1, `rgba(${n.cr},${n.cg},${n.cb},0)`)
mc.fillStyle = g
mc.fillRect(0, 0, w, h)
})
for (let i = 0; i < 200; i++) {
const sx = Math.random() * w, sy = Math.random() * h
const sr = 0.15 + Math.random() * 1.6
const pal = STAR_PALETTES[Math.floor(Math.random() * STAR_PALETTES.length)]
mc.beginPath(); mc.arc(sx, sy, sr, 0, Math.PI * 2)
mc.fillStyle = pal(0.2 + Math.random() * 0.6)
mc.fill()
}
return
}
// ── Desktop: full animated version ──────────────────────────
const ctx = canvas.getContext('2d', { alpha: false })
// Performance tier: low-end = ≤4 logical cores (older/budget hardware)
const isLowEnd = typeof navigator.hardwareConcurrency === 'number' && navigator.hardwareConcurrency <= 4
const FRAME_MS = isLowEnd ? 48 : 32 // ~20 fps vs ~30 fps
const SHARD_N = isLowEnd ? 12 : SHARD_COUNT
const NEBULA_N = isLowEnd ? 4 : NEBULAS.length
let W = 0, H = 0, rafId
let time = 0
const mouse = { x: -2000, y: -2000 }
let stars = [], shards = [], shootingStars = []
let nextShootAt = 2 + Math.random() * 4
// Resize
const resize = () => {
const dpr = Math.min(window.devicePixelRatio || 1, 2)
W = window.innerWidth; H = window.innerHeight
canvas.width = W * dpr; canvas.height = H * dpr
canvas.style.width = `${W}px`; canvas.style.height = `${H}px`
ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.scale(dpr, dpr)
initStars(); initShards()
}
// ── Stars ────────────────────────────────────────────────────
function initStars() {
stars = []
STAR_LAYERS.forEach((layer, li) => {
const count = isLowEnd ? Math.ceil(layer.count * 0.5) : layer.count
for (let i = 0; i < count; i++) {
const r = layer.rMin + Math.random() * (layer.rMax - layer.rMin)
const palIdx = Math.random() < 0.55 ? 1 // mostly white
: Math.random() < 0.40 ? 0 // blue-white
: Math.random() < 0.40 ? 3 // purple
: Math.random() < 0.50 ? 4 // lavender
: 2 // warm
stars.push({
x: Math.random() * W, y: Math.random() * H,
r,
vy: layer.speed * (0.5 + Math.random()),
vx: (Math.random() - 0.5) * layer.speed * 0.3,
phase: Math.random() * Math.PI * 2,
twinkleSpeed: 0.3 + Math.random() * (li === 0 ? 0.8 : 1.8),
alphaMax: layer.alphaMax * (0.55 + Math.random() * 0.45),
layer: li,
palIdx,
spike: li === 2 && r > 1.3 && Math.random() < 0.6,
})
}
})
}
function drawStars() {
for (let i = 0; i < stars.length; i++) {
const s = stars[i]
const tw = 0.4 + 0.6 * (0.5 + 0.5 * Math.sin(time * s.twinkleSpeed + s.phase))
s.x += s.vx; s.y += s.vy
if (s.y > H + 2) { s.y = -2; s.x = Math.random() * W }
if (s.x < -2) s.x = W + 2
if (s.x > W + 2) s.x = -2
const a = tw * s.alphaMax
const col = STAR_PALETTES[s.palIdx](a)
ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2)
ctx.fillStyle = col; ctx.fill()
// Soft halo on brighter stars
if (!isLowEnd && s.layer === 2 && s.r > 1.5) {
const halo = ctx.createRadialGradient(s.x, s.y, 0, s.x, s.y, s.r * 4)
halo.addColorStop(0, STAR_PALETTES[s.palIdx](tw * 0.18))
halo.addColorStop(1, 'rgba(0,0,0,0)')
ctx.beginPath(); ctx.arc(s.x, s.y, s.r * 4, 0, Math.PI * 2)
ctx.fillStyle = halo; ctx.fill()
}
// Diffraction spikes on select bright stars
if (!isLowEnd && s.spike) {
const spikeLen = s.r * 5.5
const sa = tw * 0.28
ctx.strokeStyle = STAR_PALETTES[s.palIdx](sa)
ctx.lineWidth = 0.6
ctx.beginPath()
ctx.moveTo(s.x - spikeLen, s.y); ctx.lineTo(s.x + spikeLen, s.y)
ctx.moveTo(s.x, s.y - spikeLen); ctx.lineTo(s.x, s.y + spikeLen)
ctx.stroke()
}
}
}
// ── Nebulas ──────────────────────────────────────────────────
function drawNebulas() {
for (let i = 0; i < NEBULA_N; i++) {
const n = NEBULAS[i]
const ox = Math.sin(time * n.spx * 800 + n.phase) * W * 0.08
const oy = Math.cos(time * n.spy * 800 + n.phase + 1.2) * H * 0.08
const cx = n.px * W + ox
const cy = n.py * H + oy
const rad = n.pr * Math.min(W, H)
const pa = n.a * (0.70 + 0.30 * Math.sin(time * 0.22 + n.phase))
const g = ctx.createRadialGradient(cx, cy, 0, cx, cy, rad)
g.addColorStop(0, `rgba(${n.cr},${n.cg},${n.cb},${pa})`)
g.addColorStop(0.40, `rgba(${n.cr},${n.cg},${n.cb},${pa * 0.40})`)
g.addColorStop(0.75, `rgba(${n.cr},${n.cg},${n.cb},${pa * 0.12})`)
g.addColorStop(1, `rgba(${n.cr},${n.cg},${n.cb},0)`)
ctx.fillStyle = g
ctx.beginPath(); ctx.arc(cx, cy, rad, 0, Math.PI * 2); ctx.fill()
}
}
// ── Shooting stars ───────────────────────────────────────────
function spawnShootingStar() {
const fromRight = Math.random() < 0.5
const sx = fromRight ? W * (0.45 + Math.random() * 0.55) : W * Math.random() * 0.75
const sy = Math.random() * H * 0.55
const angle = Math.PI / 4 + (Math.random() - 0.5) * 0.7
const speed = 7 + Math.random() * 11
const length = 90 + Math.random() * 200
const thick = 0.8 + Math.random() * 1.4
shootingStars.push({
x: sx, y: sy,
vx: Math.cos(angle) * speed * (fromRight ? -1 : 1),
vy: Math.sin(angle) * speed,
length, thick, alpha: 0, life: 0,
maxLife: (length / speed) * 1.8,
})
}
function updateShootingStars() {
if (shootingStars.length < 4 && time > nextShootAt) {
spawnShootingStar()
nextShootAt = time + 2.5 + Math.random() * 7
}
for (let i = shootingStars.length - 1; i >= 0; i--) {
const s = shootingStars[i]; s.life++
s.alpha = s.life < 8 ? s.life / 8
: s.life > s.maxLife - 10 ? Math.max(0, (s.maxLife - s.life) / 10)
: 1
s.x += s.vx; s.y += s.vy
const len = Math.hypot(s.vx, s.vy)
const tailX = s.x - (s.vx / len) * s.length
const tailY = s.y - (s.vy / len) * s.length
const g = ctx.createLinearGradient(s.x, s.y, tailX, tailY)
g.addColorStop(0, `rgba(255,255,255,${s.alpha * 0.95})`)
g.addColorStop(0.12, `rgba(220,200,255,${s.alpha * 0.65})`)
g.addColorStop(0.50, `rgba(140,80,220,${s.alpha * 0.25})`)
g.addColorStop(1, `rgba(80,40,160,0)`)
ctx.beginPath(); ctx.moveTo(s.x, s.y); ctx.lineTo(tailX, tailY)
ctx.strokeStyle = g; ctx.lineWidth = s.thick; ctx.stroke()
// bright head dot
ctx.beginPath(); ctx.arc(s.x, s.y, s.thick * 0.9, 0, Math.PI * 2)
ctx.fillStyle = `rgba(255,255,255,${s.alpha})`; ctx.fill()
if (s.life >= s.maxLife || s.x < -100 || s.x > W + 100 || s.y > H + 100)
shootingStars.splice(i, 1)
}
}
// ── Glass shards / space debris ──────────────────────────────
function initShards() {
shards = Array.from({ length: SHARD_N }, () => {
const size = 14 + Math.random() * 58
const sides = 3 + Math.floor(Math.random() * 4)
const col = SHARD_COLORS[Math.floor(Math.random() * SHARD_COLORS.length)]
const baseAlpha = 0.28 + Math.random() * 0.30
return {
x: Math.random() * W, y: Math.random() * H,
vx: (Math.random() - 0.5) * 0.22,
vy: (Math.random() - 0.5) * 0.22,
rot: Math.random() * Math.PI * 2,
rotV: (Math.random() - 0.5) * 0.003,
verts: buildVerts(size, sides),
size, col,
alpha: baseAlpha,
alphaTarget: baseAlpha,
svx: 0, svy: 0,
phase: Math.random() * Math.PI * 2,
floatSpeed: 0.20 + Math.random() * 0.45,
}
})
}
function drawShard(s) {
ctx.save()
ctx.translate(s.x, s.y); ctx.rotate(s.rot)
const { r, g, b } = s.col; const al = s.alpha
ctx.beginPath()
ctx.moveTo(s.verts[0][0], s.verts[0][1])
for (let i = 1; i < s.verts.length; i++) ctx.lineTo(s.verts[i][0], s.verts[i][1])
ctx.closePath()
// Cache gradient — only recreate when alpha shifts noticeably
if (!s._fill || Math.abs(al - s._fillAlpha) > 0.018) {
const f = ctx.createLinearGradient(-s.size, -s.size, s.size * 0.6, s.size * 0.6)
f.addColorStop(0, `rgba(${r},${g},${b},${al * 0.28})`)
f.addColorStop(0.45, `rgba(255,255,255,${al * 0.12})`)
f.addColorStop(1, `rgba(${r},${g},${b},${al * 0.06})`)
s._fill = f; s._fillAlpha = al
}
ctx.fillStyle = s._fill; ctx.fill()
ctx.strokeStyle = `rgba(${r},${g},${b},${al * 0.90})`
ctx.lineWidth = 1.2; ctx.stroke()
if (s.verts.length >= 2) {
ctx.beginPath()
ctx.moveTo(s.verts[0][0] * 0.55, s.verts[0][1] * 0.55)
ctx.lineTo(s.verts[1][0] * 0.55, s.verts[1][1] * 0.55)
ctx.strokeStyle = `rgba(255,255,255,${al * 0.55})`
ctx.lineWidth = 0.9; ctx.stroke()
}
ctx.restore()
}
function updateShards() {
const infSq = SHARD_RADIUS * SHARD_RADIUS
for (let i = 0; i < shards.length; i++) {
const s = shards[i]
s.vy += Math.sin(time * s.floatSpeed + s.phase) * 0.002
if (mouse.x > -1000) {
const dx = s.x - mouse.x, dy = s.y - mouse.y
const dSq = dx * dx + dy * dy
if (dSq < infSq) {
const dist = Math.sqrt(dSq)
const force = (1 - dist / SHARD_RADIUS) * SCATTER_FORCE
const ang = Math.atan2(dy, dx)
s.svx += Math.cos(ang) * force * 0.065
s.svy += Math.sin(ang) * force * 0.065
s.rotV += (Math.random() - 0.5) * 0.004 * (1 - dist / SHARD_RADIUS)
s.alphaTarget = Math.min(0.80, s.alphaTarget + 0.03)
}
}
s.rotV *= 0.96
s.rotV = Math.max(-ROT_V_MAX, Math.min(ROT_V_MAX, s.rotV))
s.svx *= 0.92; s.svy *= 0.92; s.vx *= 0.998; s.vy *= 0.998
s.x += s.vx + s.svx; s.y += s.vy + s.svy; s.rot += s.rotV
s.alpha += (s.alphaTarget - s.alpha) * 0.025
if (s.alphaTarget > 0.28) s.alphaTarget -= 0.004
const m = s.size + 10
if (s.x < -m) s.x = W + m; if (s.x > W + m) s.x = -m
if (s.y < -m) s.y = H + m; if (s.y > H + m) s.y = -m
}
}
// ── Cursor aura ──────────────────────────────────────────────
function drawCursorAura() {
const r = 160 + Math.sin(time * 1.8) * 28
const aura = ctx.createRadialGradient(mouse.x, mouse.y, 0, mouse.x, mouse.y, r)
aura.addColorStop(0, 'rgba(160,75,220,0.10)')
aura.addColorStop(0.5, 'rgba(139,61,184,0.035)')
aura.addColorStop(1, 'rgba(139,61,184,0)')
ctx.fillStyle = aura
ctx.beginPath(); ctx.arc(mouse.x, mouse.y, r, 0, Math.PI * 2); ctx.fill()
}
// ── Main loop — ~30 fps, paused on hidden tab ─────────────────
let lastTs = 0
function animate(ts = 0) {
rafId = requestAnimationFrame(animate)
if (document.hidden) return
if (ts - lastTs < FRAME_MS) return
lastTs = ts
time += 0.016
// Deep space background — very slightly blue-tinted black
ctx.fillStyle = '#0A0A12'
ctx.fillRect(0, 0, W, H)
drawNebulas()
drawStars()
updateShootingStars()
updateShards()
shards.forEach(s => drawShard(s))
if (mouse.x > -1000) drawCursorAura()
}
const onMouseMove = e => { mouse.x = e.clientX; mouse.y = e.clientY }
const onMouseLeave = () => { mouse.x = -2000; mouse.y = -2000 }
const debResize = (() => { let t; return () => { clearTimeout(t); t = setTimeout(resize, 180) } })()
resize()
rafId = requestAnimationFrame(animate)
window.addEventListener('resize', debResize)
document.addEventListener('mousemove', onMouseMove, { passive: true })
document.addEventListener('mouseleave', onMouseLeave)
return () => {
cancelAnimationFrame(rafId)
window.removeEventListener('resize', debResize)
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseleave', onMouseLeave)
}
}, [])
return (
<canvas
ref={canvasRef}
className="space-bg"
aria-hidden="true"
role="presentation"
/>
)
}

View File

@@ -0,0 +1,200 @@
/* ── Overlay ─────────────────────────────────────────────────── */
.sv-overlay {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(6, 6, 10, 0.82);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
animation: sv-fade-in 0.22s ease;
}
@keyframes sv-fade-in {
from { opacity: 0 }
to { opacity: 1 }
}
/* ── Modal card ─────────────────────────────────────────────── */
.sv-modal {
width: 100%;
max-width: 640px;
height: min(580px, 90vh);
background: rgba(14, 14, 22, 0.96);
border: 1px solid rgba(139, 61, 184, 0.40);
border-radius: 16px;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow:
0 0 0 1px rgba(139, 61, 184, 0.12),
0 24px 80px rgba(0, 0, 0, 0.65),
0 0 60px rgba(139, 61, 184, 0.12);
animation: sv-slide-up 0.28s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes sv-slide-up {
from { opacity: 0; transform: translateY(24px) scale(0.97) }
to { opacity: 1; transform: translateY(0) scale(1) }
}
/* ── Header ─────────────────────────────────────────────────── */
.sv-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 1.5rem 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
flex-shrink: 0;
}
.sv-title-group {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.sv-label {
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--color-accent);
}
.sv-title {
font-size: 1.1rem;
font-weight: 700;
color: var(--color-text);
letter-spacing: -0.01em;
}
.sv-close {
width: 32px;
height: 32px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.10);
color: var(--color-text-muted);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background var(--transition), color var(--transition), border-color var(--transition);
flex-shrink: 0;
}
.sv-close svg {
width: 14px;
height: 14px;
}
.sv-close:hover {
background: rgba(139, 61, 184, 0.22);
border-color: rgba(139, 61, 184, 0.50);
color: var(--color-text);
}
/* ── Canvas wrapper ─────────────────────────────────────────── */
.sv-canvas-wrap {
flex: 1;
position: relative;
overflow: hidden;
}
.sv-canvas {
width: 100%;
height: 100%;
display: block;
cursor: grab;
opacity: 0;
transition: opacity 0.35s ease;
}
.sv-canvas--ready {
opacity: 1;
}
.sv-canvas:active {
cursor: grabbing;
}
/* ── Loader ─────────────────────────────────────────────────── */
.sv-loader {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
pointer-events: none;
}
.sv-loader-ring {
width: 40px;
height: 40px;
border-radius: 50%;
border: 2px solid rgba(139, 61, 184, 0.18);
border-top-color: var(--color-accent);
animation: sv-spin 0.85s linear infinite;
}
@keyframes sv-spin {
to { transform: rotate(360deg); }
}
.sv-loader-text {
font-size: 0.72rem;
letter-spacing: 0.10em;
text-transform: uppercase;
color: var(--color-text-faint);
animation: sv-pulse 1.6s ease-in-out infinite;
}
@keyframes sv-pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 0.9; }
}
/* ── Footer ─────────────────────────────────────────────────── */
.sv-footer {
display: flex;
align-items: center;
justify-content: center;
gap: 1.5rem;
padding: 0.75rem 1.5rem;
border-top: 1px solid rgba(255, 255, 255, 0.07);
flex-shrink: 0;
}
.sv-hint {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.72rem;
color: var(--color-text-faint);
letter-spacing: 0.03em;
}
.sv-hint svg {
opacity: 0.6;
flex-shrink: 0;
}
@media (max-width: 480px) {
.sv-modal {
height: min(480px, 88vh);
}
.sv-footer {
gap: 1rem;
}
.sv-hint {
font-size: 0.68rem;
}
}

View File

@@ -0,0 +1,422 @@
import { useEffect, useRef, useState } from 'react'
import './StairViewer.css'
// =================================================================
// L-SHAPED STAIRCASE — two flights + landing, cable railing
// =================================================================
function buildStairGeometry() {
const verts = [], edges = []
function box(x1,y1,z1, x2,y2,z2, type='structure') {
const b = verts.length
verts.push(
[x1,y1,z1],[x2,y1,z1],[x2,y1,z2],[x1,y1,z2],
[x1,y2,z1],[x2,y2,z1],[x2,y2,z2],[x1,y2,z2],
)
edges.push(
[b,b+1,type],[b+1,b+2,type],[b+2,b+3,type],[b+3,b,type],
[b+4,b+5,type],[b+5,b+6,type],[b+6,b+7,type],[b+7,b+4,type],
[b,b+4,type],[b+1,b+5,type],[b+2,b+6,type],[b+3,b+7,type],
)
}
function ln(x1,y1,z1, x2,y2,z2, type='structure') {
const b = verts.length
verts.push([x1,y1,z1],[x2,y2,z2])
edges.push([b,b+1,type])
}
// Circle in YZ plane — for flight-1 cable rings
function circleYZ(x, cy, cz, r, n=8, type='ring') {
const b = verts.length
for (let i=0; i<n; i++) {
const a = (i/n)*Math.PI*2
verts.push([x, cy+r*Math.cos(a), cz+r*Math.sin(a)])
}
for (let i=0; i<n; i++) edges.push([b+i, b+(i+1)%n, type])
}
// Circle in XY plane for flight-2 cable rings
function circleXY(cx, cy, z, r, n=8, type='ring') {
const b = verts.length
for (let i=0; i<n; i++) {
const a = (i/n)*Math.PI*2
verts.push([cx+r*Math.cos(a), cy+r*Math.sin(a), z])
}
for (let i=0; i<n; i++) edges.push([b+i, b+(i+1)%n, type])
}
// DIMENSIONS
const N1 = 6, N2 = 6 // steps per flight
const TD = 0.270 // tread depth
const TR = 0.178 // rise per step
const TH = 0.095 // tread thickness
const W = 1.08 // stair width
// Centre offsets so entire model is at origin
const CX = W/2 + (N2*TD)/2 // = 0.54 + 0.81 = 1.35
const CY = (N1+N2)*TR / 2 // = 1.068
const CZ = (N1*TD)/2 + W/2 // = 0.81 + 0.54 = 1.35
// Flight 1: treads going in +Z, width in X
const F1 = Array.from({length: N1}, (_, i) => ({
x1: -CX, x2: -CX + W,
y: i*TR - CY, y2: i*TR - CY + TH,
z1: i*TD - CZ, z2: (i+1)*TD - CZ,
}))
// Landing at top of flight 1
const LY = N1*TR - CY // = 0 (perfect centre in Y!)
const LX1 = -CX, LX2 = -CX + W
const LZ1 = N1*TD - CZ // = 0.27
const LZ2 = N1*TD + W - CZ // = 1.35
// Flight 2: treads going in +X, width in Z
const F2 = Array.from({length: N2}, (_, j) => ({
z1: LZ1, z2: LZ2,
x1: LX2 + j*TD, x2: LX2 + (j+1)*TD,
y: LY + j*TR, y2: LY + j*TR + TH,
}))
// ── BOTTOM FLOOR LANDING ──────────────────────────────────────
box(F1[0].x1, F1[0].y-0.26, F1[0].z1-0.52, F1[0].x2, F1[0].y, F1[0].z1)
// ── FLIGHT 1 TREADS ──────────────────────────────────────────
for (const s of F1) box(s.x1, s.y, s.z1, s.x2, s.y2, s.z2)
// ── CORNER LANDING ────────────────────────────────────────────
box(LX1, LY, LZ1, LX2, LY+TH, LZ2)
// ── FLIGHT 2 TREADS ──────────────────────────────────────────
for (const s of F2) box(s.x1, s.y, s.z1, s.x2, s.y2, s.z2)
// ── TOP LANDING ───────────────────────────────────────────────
box(F2[N2-1].x2, F2[N2-1].y+TH, LZ1, F2[N2-1].x2+0.44, F2[N2-1].y2+0.026, LZ2)
// ── SPINE FLIGHT 1 (central diagonal beam under treads) ───────
const SP1X = (F1[0].x1+F1[0].x2)/2 // = -0.81
const SPW = 0.028
ln(SP1X-SPW, F1[0].y-0.26, F1[0].z1, SP1X-SPW, LY+TH, LZ1)
ln(SP1X+SPW, F1[0].y-0.26, F1[0].z1, SP1X+SPW, LY+TH, LZ1)
for (const s of F1) {
ln(SP1X-SPW, s.y, s.z1+0.05, SP1X+SPW, s.y, s.z1+0.05, 'cross')
ln(SP1X-SPW, s.y, s.z2-0.05, SP1X+SPW, s.y, s.z2-0.05, 'cross')
}
// ── SPINE FLIGHT 2 ────────────────────────────────────────────
const SP2Z = (LZ1+LZ2)/2 // = 0.81
ln(F2[0].x1, F2[0].y, SP2Z-SPW, F2[N2-1].x2, F2[N2-1].y+TH, SP2Z-SPW)
ln(F2[0].x1, F2[0].y, SP2Z+SPW, F2[N2-1].x2, F2[N2-1].y+TH, SP2Z+SPW)
for (const s of F2) {
ln(s.x1+0.05, s.y, SP2Z-SPW, s.x1+0.05, s.y, SP2Z+SPW, 'cross')
ln(s.x2-0.05, s.y, SP2Z-SPW, s.x2-0.05, s.y, SP2Z+SPW, 'cross')
}
// ── INNER CORNER NEWEL POST ───────────────────────────────────
box(LX2-0.052, LY, LZ1-0.052, LX2+0.052, LY+1.58, LZ1+0.052, 'post')
box(LX2-0.066, LY+1.58, LZ1-0.066, LX2+0.066, LY+1.64, LZ1+0.066, 'post')
// ── STRINGER FLIGHT 1 (inner/wall side, x = LX2 = -0.27) ──────
{
const SX = LX2
const SX2 = SX + 0.040
const BY = F1[0].y - 0.34
ln(SX, BY, F1[0].z1, SX, BY, F1[N1-1].z2)
ln(SX, BY, F1[0].z1, SX, F1[0].y, F1[0].z1)
ln(SX, BY, F1[N1-1].z2, SX, F1[N1-1].y2, F1[N1-1].z2)
ln(SX, F1[0].y, F1[0].z1, SX, F1[N1-1].y2, F1[N1-1].z2)
ln(SX2, BY, F1[0].z1, SX2, BY, F1[N1-1].z2)
ln(SX2, BY, F1[0].z1, SX2, F1[0].y, F1[0].z1)
ln(SX2, BY, F1[N1-1].z2, SX2, F1[N1-1].y2, F1[N1-1].z2)
ln(SX2, F1[0].y, F1[0].z1, SX2, F1[N1-1].y2, F1[N1-1].z2)
ln(SX, BY, F1[0].z1, SX2, BY, F1[0].z1)
ln(SX, F1[0].y, F1[0].z1, SX2, F1[0].y, F1[0].z1)
ln(SX, BY, F1[N1-1].z2, SX2, BY, F1[N1-1].z2)
ln(SX, F1[N1-1].y2, F1[N1-1].z2, SX2, F1[N1-1].y2, F1[N1-1].z2)
}
// ── STRINGER FLIGHT 2 (inner/wall side, z = LZ1 = 0.27) ───────
{
const SZ = LZ1
const SZ2 = SZ - 0.040
const BY = F2[0].y - 0.18
ln(F2[0].x1, SZ, BY, F2[0].x1, SZ, F2[0].y)
ln(F2[N2-1].x2, SZ, BY, F2[N2-1].x2, SZ, F2[N2-1].y2)
ln(F2[0].x1, SZ, BY, F2[N2-1].x2, SZ, BY)
ln(F2[0].x1, SZ, F2[0].y, F2[N2-1].x2, SZ, F2[N2-1].y2)
ln(F2[0].x1, SZ2, BY, F2[0].x1, SZ2, F2[0].y)
ln(F2[N2-1].x2, SZ2, BY, F2[N2-1].x2, SZ2, F2[N2-1].y2)
ln(F2[0].x1, SZ2, BY, F2[N2-1].x2, SZ2, BY)
ln(F2[0].x1, SZ2, F2[0].y, F2[N2-1].x2, SZ2, F2[N2-1].y2)
ln(F2[0].x1, SZ, BY, F2[0].x1, SZ2, BY)
ln(F2[0].x1, SZ, F2[0].y, F2[0].x1, SZ2, F2[0].y)
ln(F2[N2-1].x2, SZ, BY, F2[N2-1].x2, SZ2, BY)
ln(F2[N2-1].x2, SZ, F2[N2-1].y2, F2[N2-1].x2, SZ2, F2[N2-1].y2)
}
// ── RAILING FLIGHT 1 (open/left side, x = LX1 = -1.35) ───────
{
const RH = 0.92, cB = 0.09, cT = RH-0.06
const RL = LX1
const pSt = [0, 3, 5]
// Bottom newel (extends to floor)
box(RL, F1[0].y-0.26, F1[0].z1-0.040, RL+0.080, F1[0].y, F1[0].z1+0.040, 'post')
box(RL, F1[0].y+RH, F1[0].z1-0.040, RL+0.080, F1[0].y+RH+0.058, F1[0].z1+0.040, 'post')
for (const pi of pSt) {
const s = F1[pi], pw = pi===0 ? 0.040 : 0.026
box(RL, s.y, s.z1-pw, RL+pw*2, s.y+RH, s.z1+pw, 'post')
}
// Handrail
const rx = RL+0.042
ln(rx, F1[0].y+RH, F1[0].z1, rx, F1[N1-1].y+RH, F1[N1-1].z1, 'rail')
ln(rx, F1[0].y+RH+0.044, F1[0].z1, rx, F1[N1-1].y+RH+0.044, F1[N1-1].z1, 'rail')
// X-cables
const cx = RL+0.020
for (let k=0; k<pSt.length-1; k++) {
const a=F1[pSt[k]], b2=F1[pSt[k+1]]
ln(cx, a.y+cB, a.z1, cx, b2.y+cT, b2.z1, 'cable')
ln(cx, a.y+cT, a.z1, cx, b2.y+cB, b2.z1, 'cable')
circleYZ(cx, (a.y+cB+b2.y+cT)*0.5, (a.z1+b2.z1)*0.5, 0.024, 8)
}
// Full-span diagonals
ln(cx, F1[0].y+cB, F1[0].z1, cx, F1[N1-1].y+cT, F1[N1-1].z1, 'cable')
ln(cx, F1[0].y+cT, F1[0].z1, cx, F1[N1-1].y+cB, F1[N1-1].z1, 'cable')
circleYZ(cx, (F1[0].y+cB+F1[N1-1].y+cT)*0.5, (F1[0].z1+F1[N1-1].z1)*0.5, 0.028, 8)
for (const pi of pSt) {
circleYZ(cx, F1[pi].y+cB, F1[pi].z1, 0.020, 7)
circleYZ(cx, F1[pi].y+cT, F1[pi].z1, 0.020, 7)
}
}
// RAILING FLIGHT 2 (open/back side, z = LZ2 = 1.35)
{
const RH = 0.92, cB = 0.09, cT = RH-0.06
const RL = LZ2
const pSt = [0, 3, 5]
// Top newel (at end of flight 2)
box(F2[N2-1].x2-0.040, F2[N2-1].y, RL-0.080, F2[N2-1].x2+0.040, F2[N2-1].y+RH, RL, 'post')
box(F2[N2-1].x2-0.040, F2[N2-1].y+RH, RL-0.080, F2[N2-1].x2+0.040, F2[N2-1].y+RH+0.058, RL, 'post')
for (const pj of pSt) {
const s = F2[pj], pw = 0.026
box(s.x1-pw, s.y, RL-pw*2, s.x1+pw, s.y+RH, RL, 'post')
}
// Handrail
const rz = RL-0.042
ln(F2[0].x1, F2[0].y+RH, rz, F2[N2-1].x2, F2[N2-1].y+RH, rz, 'rail')
ln(F2[0].x1, F2[0].y+RH+0.044, rz, F2[N2-1].x2, F2[N2-1].y+RH+0.044, rz, 'rail')
// X-cables
const cz = RL-0.020
for (let k=0; k<pSt.length-1; k++) {
const a=F2[pSt[k]], b2=F2[pSt[k+1]]
ln(a.x1, a.y+cB, cz, b2.x1, b2.y+cT, cz, 'cable')
ln(a.x1, a.y+cT, cz, b2.x1, b2.y+cB, cz, 'cable')
circleXY((a.x1+b2.x1)*0.5, (a.y+cB+b2.y+cT)*0.5, cz, 0.024, 8)
}
// Full-span diagonals
ln(F2[0].x1, F2[0].y+cB, cz, F2[N2-1].x1, F2[N2-1].y+cT, cz, 'cable')
ln(F2[0].x1, F2[0].y+cT, cz, F2[N2-1].x1, F2[N2-1].y+cB, cz, 'cable')
circleXY((F2[0].x1+F2[N2-1].x1)*0.5, (F2[0].y+cB+F2[N2-1].y+cT)*0.5, cz, 0.028, 8)
for (const pj of pSt) {
circleXY(F2[pj].x1, F2[pj].y+cB, cz, 0.020, 7)
circleXY(F2[pj].x1, F2[pj].y+cT, cz, 0.020, 7)
}
}
return { verts, edges }
}
// =================================================================
// ENGINE
// =================================================================
export default function StairViewer({ onClose }) {
const canvasRef = useRef(null)
const [ready, setReady] = useState(false)
const stateRef = useRef({
rotX: 0.32, rotY: -0.48,
dragging: false, lastX: 0, lastY: 0,
autoRotate: true, idleTimer: null,
zoom: 0.70, rafId: null,
lastTs: 0,
})
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
const state = stateRef.current
const { verts, edges } = buildStairGeometry()
const resize = () => {
const dpr = Math.min(window.devicePixelRatio || 1, 2)
canvas.width = canvas.offsetWidth * dpr
canvas.height = canvas.offsetHeight * dpr
}
resize()
const ro = new ResizeObserver(resize)
ro.observe(canvas)
function project(v) {
let [x, y, z] = v
const { rotX, rotY, zoom } = state
const dpr = Math.min(window.devicePixelRatio || 1, 2)
const W = canvas.width / dpr
const H = canvas.height / dpr
const cy_ = Math.cos(rotY), sy_ = Math.sin(rotY)
const x1 = x * cy_ - z * sy_
const z1 = x * sy_ + z * cy_
const cx_ = Math.cos(rotX), sx_ = Math.sin(rotX)
const y2 = y * cx_ - z1 * sx_
const z2 = y * sx_ + z1 * cx_
const fov = Math.min(W, H) * 1.75
const scale = Math.min(W, H) * 0.095 * zoom
const d = fov + z2
return { x: (x1/d)*fov*scale + W/2, y: (-y2/d)*fov*scale + H/2, z: z2 }
}
function render() {
const dpr = Math.min(window.devicePixelRatio || 1, 2)
const W = canvas.width / dpr
const H = canvas.height / dpr
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, W, H)
const proj = verts.map(v => project(v))
let zMin = Infinity, zMax = -Infinity
for (const p of proj) {
if (p.z < zMin) zMin = p.z
if (p.z > zMax) zMax = p.z
}
const zRange = zMax - zMin || 1
const sorted = edges
.map(([a, b, type]) => ({ a, b, type, depth: (proj[a].z + proj[b].z) * 0.5 }))
.sort((e1, e2) => e2.depth - e1.depth)
for (const { a, b, type, depth } of sorted) {
const pa = proj[a], pb = proj[b]
const t = (depth - zMin) / zRange
const da = Math.max(0.10, Math.min(1.0, 1.05 - t * 0.88))
let color, lw
if (type === 'rail') { color = `rgba(215,155,255,${da*1.00})`; lw = 1.9 }
else if (type === 'post') { color = `rgba(200,130,255,${da*0.92})`; lw = 1.5 }
else if (type === 'cable') { color = `rgba(175, 95,255,${da*0.72})`; lw = 0.9 }
else if (type === 'ring') { color = `rgba(215,160,255,${da*0.88})`; lw = 1.2 }
else if (type === 'cross') { color = `rgba(165, 80,247,${da*0.55})`; lw = 0.75}
else { color = `rgba(190,110,255,${da})` ; lw = 1.3 }
ctx.beginPath(); ctx.moveTo(pa.x, pa.y); ctx.lineTo(pb.x, pb.y)
ctx.strokeStyle = color; ctx.lineWidth = lw; ctx.stroke()
}
for (const p of proj) {
const t = (p.z - zMin) / zRange
const da = Math.max(0.08, 0.45 - t * 0.35)
ctx.beginPath(); ctx.arc(p.x, p.y, 1.4, 0, Math.PI * 2)
ctx.fillStyle = `rgba(220,170,255,${da})`; ctx.fill()
}
}
let firstFrame = true
function loop(ts) {
state.rafId = requestAnimationFrame(loop)
if (ts - state.lastTs < 32) return
state.lastTs = ts
if (state.autoRotate && !state.dragging) state.rotY += 0.006
render()
if (firstFrame) { firstFrame = false; setReady(true) }
}
state.rafId = requestAnimationFrame(loop)
const stopAutoRotate = () => {
state.autoRotate = false
clearTimeout(state.idleTimer)
state.idleTimer = setTimeout(() => { state.autoRotate = true }, 2200)
}
const onMouseDown = (e) => { state.dragging=true; state.lastX=e.clientX; state.lastY=e.clientY; stopAutoRotate() }
const onMouseMove = (e) => {
if (!state.dragging) return
state.rotY += (e.clientX-state.lastX)*0.008; state.rotX += (e.clientY-state.lastY)*0.008
state.rotX = Math.max(-0.75,Math.min(0.75,state.rotX)); state.lastX=e.clientX; state.lastY=e.clientY
}
const onMouseUp = () => { state.dragging=false }
const onWheel = (e) => {
e.preventDefault(); state.zoom *= e.deltaY>0 ? 0.93 : 1.07
state.zoom = Math.max(0.35,Math.min(3.0,state.zoom)); stopAutoRotate()
}
let lastTX=0, lastTY=0, lastPinchDist=0
const onTouchStart = (e) => {
stopAutoRotate(); state.dragging=true
if (e.touches.length===1) { lastTX=e.touches[0].clientX; lastTY=e.touches[0].clientY }
else if (e.touches.length===2) lastPinchDist=Math.hypot(e.touches[0].clientX-e.touches[1].clientX,e.touches[0].clientY-e.touches[1].clientY)
}
const onTouchMove = (e) => {
if (!state.dragging) return
if (e.touches.length===1) {
state.rotY+=(e.touches[0].clientX-lastTX)*0.008; state.rotX+=(e.touches[0].clientY-lastTY)*0.008
state.rotX=Math.max(-0.75,Math.min(0.75,state.rotX)); lastTX=e.touches[0].clientX; lastTY=e.touches[0].clientY
} else if (e.touches.length===2) {
const dist=Math.hypot(e.touches[0].clientX-e.touches[1].clientX,e.touches[0].clientY-e.touches[1].clientY)
if (lastPinchDist) state.zoom*=dist/lastPinchDist
state.zoom=Math.max(0.35,Math.min(3.0,state.zoom)); lastPinchDist=dist
}
}
const onTouchEnd = () => { state.dragging=false; lastPinchDist=0 }
canvas.addEventListener('mousedown', onMouseDown)
window.addEventListener('mousemove', onMouseMove)
window.addEventListener('mouseup', onMouseUp)
canvas.addEventListener('wheel', onWheel, { passive: false })
canvas.addEventListener('touchstart', onTouchStart, { passive: true })
canvas.addEventListener('touchmove', onTouchMove, { passive: true })
canvas.addEventListener('touchend', onTouchEnd)
const onKeyDown = (e) => { if (e.key==='Escape') onClose() }
window.addEventListener('keydown', onKeyDown)
return () => {
cancelAnimationFrame(state.rafId); clearTimeout(state.idleTimer); ro.disconnect(); setReady(false)
canvas.removeEventListener('mousedown', onMouseDown); window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('mouseup', onMouseUp); canvas.removeEventListener('wheel', onWheel)
canvas.removeEventListener('touchstart', onTouchStart);canvas.removeEventListener('touchmove', onTouchMove)
canvas.removeEventListener('touchend', onTouchEnd); window.removeEventListener('keydown', onKeyDown)
}
}, [onClose])
return (
<div className="sv-overlay" onClick={onClose}>
<div className="sv-modal" onClick={e => e.stopPropagation()}>
<div className="sv-header">
<div className="sv-title-group">
<span className="sv-label">3D Modell</span>
<h3 className="sv-title">Treppe</h3>
</div>
<button className="sv-close" onClick={onClose} aria-label="Schließen">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
</svg>
</button>
</div>
<div className="sv-canvas-wrap">
<canvas ref={canvasRef} className={`sv-canvas${ready ? ' sv-canvas--ready' : ''}`} />
{!ready && (
<div className="sv-loader" aria-label="Wird geladen">
<div className="sv-loader-ring" />
<span className="sv-loader-text">Modell wird geladen</span>
</div>
)}
</div>
<div className="sv-footer">
<span className="sv-hint">Ziehen zum Drehen</span>
<span className="sv-hint">Scrollen zum Zoomen</span>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,22 @@
.staircase {
position: fixed;
left: 0;
top: 0;
height: 100vh;
width: 130px;
z-index: 5;
pointer-events: none;
user-select: none;
}
.staircase canvas {
display: block;
}
/* Show only when the 130px staircase fits in the left gutter
(viewport 1200px content) / 2 ≥ 130px → viewport ≥ 1460px */
@media (max-width: 1460px) {
.staircase {
display: none;
}
}

View File

@@ -0,0 +1,244 @@
import { useEffect, useRef } from 'react'
import './StaircaseScroller.css'
// ── Accent colour ────────────────────────────────────────────────
const [PR, PG, PB] = [139, 61, 184]
const c = (a) => `rgba(${PR},${PG},${PB},${a})`
// ── Layout ───────────────────────────────────────────────────────
const W = 130 // canvas logical width (px)
const CX = 68 // helix centre x
const R_OUT = 40 // outer step radius
const R_IN = 11 // inner (pole) radius
const DEPTH_X = 7 // z → screen-x perspective coefficient
const DEPTH_Y = 3.5 // z → screen-y perspective coefficient
// ── Helix parameters ────────────────────────────────────────────
const STEPS_PER_REV = 8
const NUM_REV = 5
const TOTAL_STEPS = STEPS_PER_REV * NUM_REV // 40
const SEGS_PER_STEP = 24 // sub-segments for smooth helix line
// Project an angle + vertical fraction to 2D canvas coords
function pt(angle, yFrac, H, r = R_OUT) {
const s = Math.sin(angle)
const co = Math.cos(angle)
return {
x: CX + r * s - co * DEPTH_X,
y: yFrac * H + co * DEPTH_Y,
z: co, // depth: +1 = front, -1 = back
}
}
// ── Cube helpers ─────────────────────────────────────────────────
const CUBE_VERTS_RAW = [
[-1,-1,-1],[1,-1,-1],[1,1,-1],[-1,1,-1],
[-1,-1, 1],[1,-1, 1],[1,1, 1],[-1,1, 1],
]
const CUBE_EDGES = [
[0,1],[1,2],[2,3],[3,0],
[4,5],[5,6],[6,7],[7,4],
[0,4],[1,5],[2,6],[3,7],
]
function rotY(v, a) { return [ v[0]*Math.cos(a)+v[2]*Math.sin(a), v[1], -v[0]*Math.sin(a)+v[2]*Math.cos(a) ] }
function rotX(v, a) { return [ v[0], v[1]*Math.cos(a)-v[2]*Math.sin(a), v[1]*Math.sin(a)+v[2]*Math.cos(a) ] }
function drawCube(ctx, ox, oy, size, ry, rx) {
const verts = CUBE_VERTS_RAW.map((v) => {
const [x, y, z] = rotX(rotY(v, ry), rx)
return {
sx: ox + x * size + z * size * 0.2,
sy: oy + y * size + z * size * 0.1,
z,
}
})
const sorted = CUBE_EDGES
.map(([a, b]) => ({ a, b, avgZ: (verts[a].z + verts[b].z) / 2 }))
.sort((p, q) => p.avgZ - q.avgZ)
sorted.forEach(({ a, b, avgZ }) => {
const norm = (avgZ + 1) / 2 // 0..1, front = 1
const alpha = 0.28 + norm * 0.72
ctx.beginPath()
ctx.moveTo(verts[a].sx, verts[a].sy)
ctx.lineTo(verts[b].sx, verts[b].sy)
ctx.strokeStyle = c(alpha)
ctx.lineWidth = norm > 0.5 ? 1.8 : 1.0
ctx.stroke()
})
// glow
const grd = ctx.createRadialGradient(ox, oy, 0, ox, oy, 11)
grd.addColorStop(0, c(0.28))
grd.addColorStop(1, c(0))
ctx.fillStyle = grd
ctx.beginPath(); ctx.arc(ox, oy, 11, 0, Math.PI * 2); ctx.fill()
// bright dot
ctx.beginPath(); ctx.arc(ox, oy, 2.6, 0, Math.PI * 2)
ctx.fillStyle = c(1); ctx.fill()
}
// ── Main draw ────────────────────────────────────────────────────
function draw(ctx, H, scrollY) {
ctx.clearRect(0, 0, W, H)
const maxScroll = Math.max(1, document.documentElement.scrollHeight - window.innerHeight)
const progress = Math.min(1, Math.max(0, scrollY / maxScroll))
const TOTAL_SEGS = TOTAL_STEPS * SEGS_PER_STEP
// ── 1. Central pole ─────────────────────────────────────────
const poleGrad = ctx.createLinearGradient(0, 0, 0, H)
poleGrad.addColorStop(0, c(0))
poleGrad.addColorStop(0.08, c(0.22))
poleGrad.addColorStop(0.92, c(0.22))
poleGrad.addColorStop(1, c(0))
ctx.beginPath()
ctx.moveTo(CX, 0); ctx.lineTo(CX, H)
ctx.strokeStyle = poleGrad
ctx.lineWidth = 1; ctx.stroke()
// ── 2. Continuous outer helix (connected!) ───────────────────
// Split into small segments so each segment can have its own
// depth-based opacity → smooth front/back shading, no gaps
ctx.lineJoin = 'round'
ctx.lineCap = 'round'
for (let i = 0; i < TOTAL_SEGS; i++) {
const t0 = i / TOTAL_SEGS
const t1 = (i + 1) / TOTAL_SEGS
const a0 = t0 * NUM_REV * Math.PI * 2
const a1 = t1 * NUM_REV * Math.PI * 2
const p0 = pt(a0, t0, H, R_OUT)
const p1 = pt(a1, t1, H, R_OUT)
const avgZ = (p0.z + p1.z) / 2
const front = avgZ > 0
const alpha = front
? 0.55 + avgZ * 0.30 // 0.550.85 at front
: 0.14 + (avgZ + 1) * 0.06 // 0.140.20 at back
ctx.beginPath()
ctx.moveTo(p0.x, p0.y)
ctx.lineTo(p1.x, p1.y)
ctx.strokeStyle = c(alpha)
ctx.lineWidth = front ? 1.3 : 0.75
ctx.stroke()
}
// ── 3. Continuous inner helix (near the pole) ────────────────
for (let i = 0; i < TOTAL_SEGS; i++) {
const t0 = i / TOTAL_SEGS
const t1 = (i + 1) / TOTAL_SEGS
const a0 = t0 * NUM_REV * Math.PI * 2
const a1 = t1 * NUM_REV * Math.PI * 2
const p0 = pt(a0, t0, H, R_IN)
const p1 = pt(a1, t1, H, R_IN)
const avgZ = (p0.z + p1.z) / 2
const front = avgZ > 0
const alpha = front ? 0.30 : 0.08
ctx.beginPath()
ctx.moveTo(p0.x, p0.y)
ctx.lineTo(p1.x, p1.y)
ctx.strokeStyle = c(alpha)
ctx.lineWidth = 0.65
ctx.stroke()
}
// ── 4. Radial step lines (nosing edges) ───────────────────────
for (let i = 0; i <= TOTAL_STEPS; i++) {
const angle = (i / STEPS_PER_REV) * Math.PI * 2
const yFrac = i / TOTAL_STEPS
const outer = pt(angle, yFrac, H, R_OUT)
const inner = pt(angle, yFrac, H, R_IN)
const front = outer.z > 0
const alpha = front ? 0.48 : 0.11
ctx.beginPath()
ctx.moveTo(inner.x, inner.y)
ctx.lineTo(outer.x, outer.y)
ctx.strokeStyle = c(alpha)
ctx.lineWidth = front ? 1.0 : 0.55
ctx.stroke()
}
// ── 5. Geometric figure ──────────────────────────────────────
const figAngle = progress * NUM_REV * Math.PI * 2
const figPt = pt(figAngle, progress, H)
const ry = progress * Math.PI * 7
const rx = progress * Math.PI * 4.5
drawCube(ctx, figPt.x, figPt.y, 10, ry, rx)
// ── 6. Fade edges into page background ───────────────────────
const BG = '13,13,13'
const fadeH = 80
const top = ctx.createLinearGradient(0, 0, 0, fadeH)
top.addColorStop(0, `rgba(${BG},1)`); top.addColorStop(1, `rgba(${BG},0)`)
ctx.fillStyle = top; ctx.fillRect(0, 0, W, fadeH)
const bot = ctx.createLinearGradient(0, H - fadeH, 0, H)
bot.addColorStop(0, `rgba(${BG},0)`); bot.addColorStop(1, `rgba(${BG},1)`)
ctx.fillStyle = bot; ctx.fillRect(0, H - fadeH, W, fadeH)
}
// ── Component ────────────────────────────────────────────────────
export default function StaircaseScroller() {
const canvasRef = useRef(null)
const rafRef = useRef(null)
const lastScrollY = useRef(-1)
const ctxRef = useRef(null)
const HRef = useRef(window.innerHeight)
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const dpr = Math.min(window.devicePixelRatio || 1, 2)
const ctx = canvas.getContext('2d')
ctxRef.current = ctx
const applySize = () => {
HRef.current = window.innerHeight
canvas.width = W * dpr
canvas.height = HRef.current * dpr
canvas.style.width = `${W}px`
canvas.style.height = `${HRef.current}px`
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
}
// ── RAF polling loop — catches ALL scroll methods ────────────
// (wheel, keyboard, middle-mouse autoscroll, touch, programmatic)
const tick = () => {
const sy = window.scrollY
if (sy !== lastScrollY.current) {
lastScrollY.current = sy
draw(ctx, HRef.current, sy)
}
rafRef.current = requestAnimationFrame(tick)
}
const onResize = () => { applySize(); draw(ctx, HRef.current, window.scrollY) }
applySize()
draw(ctx, HRef.current, window.scrollY)
rafRef.current = requestAnimationFrame(tick)
window.addEventListener('resize', onResize, { passive: true })
return () => {
cancelAnimationFrame(rafRef.current)
window.removeEventListener('resize', onResize)
}
}, [])
return (
<div className="staircase" aria-hidden="true" role="presentation">
<canvas ref={canvasRef} />
</div>
)
}

View File

@@ -0,0 +1,162 @@
.trv-overlay {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(6, 6, 10, 0.82);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
animation: trv-fade-in 0.22s ease;
}
@keyframes trv-fade-in {
from { opacity: 0 }
to { opacity: 1 }
}
.trv-modal {
width: 100%;
max-width: 640px;
height: min(580px, 90vh);
background: rgba(14, 14, 22, 0.96);
border: 1px solid rgba(139, 61, 184, 0.40);
border-radius: 16px;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow:
0 0 0 1px rgba(139, 61, 184, 0.12),
0 24px 80px rgba(0, 0, 0, 0.65),
0 0 60px rgba(139, 61, 184, 0.12);
animation: trv-slide-up 0.28s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes trv-slide-up {
from { opacity: 0; transform: translateY(24px) scale(0.97) }
to { opacity: 1; transform: translateY(0) scale(1) }
}
.trv-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 1.5rem 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
flex-shrink: 0;
}
.trv-title-group {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.trv-label {
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--color-accent);
}
.trv-title {
font-size: 1.1rem;
font-weight: 700;
color: var(--color-text);
letter-spacing: -0.01em;
}
.trv-close {
width: 32px;
height: 32px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.10);
color: var(--color-text-muted);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background var(--transition), color var(--transition), border-color var(--transition);
flex-shrink: 0;
}
.trv-close svg { width: 14px; height: 14px; }
.trv-close:hover {
background: rgba(139, 61, 184, 0.22);
border-color: rgba(139, 61, 184, 0.50);
color: var(--color-text);
}
.trv-canvas-wrap {
flex: 1;
position: relative;
overflow: hidden;
}
.trv-canvas {
width: 100%;
height: 100%;
display: block;
cursor: grab;
opacity: 0;
transition: opacity 0.35s ease;
}
.trv-canvas--ready { opacity: 1; }
.trv-canvas:active { cursor: grabbing; }
.trv-loader {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
pointer-events: none;
}
.trv-loader-ring {
width: 40px;
height: 40px;
border-radius: 50%;
border: 2px solid rgba(139, 61, 184, 0.18);
border-top-color: var(--color-accent);
animation: trv-spin 0.85s linear infinite;
}
@keyframes trv-spin { to { transform: rotate(360deg); } }
.trv-loader-text {
font-size: 0.72rem;
letter-spacing: 0.10em;
text-transform: uppercase;
color: var(--color-text-faint);
animation: trv-pulse 1.6s ease-in-out infinite;
}
@keyframes trv-pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 0.9; }
}
.trv-footer {
display: flex;
align-items: center;
justify-content: center;
gap: 1.5rem;
padding: 0.75rem 1.5rem;
border-top: 1px solid rgba(255, 255, 255, 0.07);
flex-shrink: 0;
}
.trv-hint {
font-size: 0.72rem;
color: var(--color-text-faint);
letter-spacing: 0.03em;
}

View File

@@ -0,0 +1,382 @@
import { useEffect, useRef, useState } from 'react'
import './TerraceViewer.css'
// =================================================================
// TERRACE GEOMETRY
// =================================================================
function buildTerrace() {
const V = [], E = []
function ring(cx, y, cz, rx, rz, n = 24) {
const b = V.length
for (let i = 0; i < n; i++) {
const a = (i / n) * Math.PI * 2
V.push([cx + rx * Math.cos(a), y, cz + rz * Math.sin(a)])
}
for (let i = 0; i < n; i++) E.push([b + i, b + (i + 1) % n])
return b
}
function connectRings(b1, b2, n) {
for (let i = 0; i < n; i++) E.push([b1 + i, b2 + i])
}
function box(x1, y1, z1, x2, y2, z2) {
const b = V.length
V.push(
[x1,y1,z1],[x2,y1,z1],[x2,y1,z2],[x1,y1,z2],
[x1,y2,z1],[x2,y2,z1],[x2,y2,z2],[x1,y2,z2],
)
E.push(
[b,b+1],[b+1,b+2],[b+2,b+3],[b+3,b],
[b+4,b+5],[b+5,b+6],[b+6,b+7],[b+7,b+4],
[b,b+4],[b+1,b+5],[b+2,b+6],[b+3,b+7],
)
}
function line(x1,y1,z1, x2,y2,z2) {
const b = V.length
V.push([x1,y1,z1],[x2,y2,z2])
E.push([b,b+1])
}
function cylinder(cx, y1, cz, rx, rz, y2, n = 16) {
const b1 = ring(cx, y1, cz, rx, rz, n)
const b2 = ring(cx, y2, cz, rx, rz, n)
connectRings(b1, b2, n)
}
const TX = 2.10, FL = -0.40
const ZN = -2.40 // back — house side
const ZF = 2.40 // front — open side (steps)
const RH = FL + 0.96 // railing top
// ── FLOOR ──────────────────────────────────────────────────────
const fp = V.length
V.push([-TX,FL,ZN],[TX,FL,ZN],[TX,FL,ZF],[-TX,FL,ZF])
E.push([fp,fp+1],[fp+1,fp+2],[fp+2,fp+3],[fp+3,fp])
// Paving tile grid — x lines
for (let x = -TX + 0.60; x < TX; x += 0.60)
line(x, FL, ZN, x, FL, ZF)
// Paving tile grid — z lines
for (let z = ZN + 0.60; z < ZF; z += 0.60)
line(-TX, FL, z, TX, FL, z)
// ── HOUSE WALL / SLIDING DOOR ──────────────────────────────────
box(-TX, FL, ZN - 0.06, TX, FL + 0.10, ZN) // threshold ledge
{
const h = V.length
V.push(
[-0.72, FL+0.08, ZN],[ 0.72, FL+0.08, ZN],
[ 0.72, FL+0.08+2.10, ZN],[-0.72, FL+0.08+2.10, ZN],
)
E.push([h,h+1],[h+1,h+2],[h+2,h+3],[h+3,h])
// Centre divider (two sliding panels)
line(0.0, FL+0.08, ZN, 0.0, FL+0.08+2.10, ZN)
// Handles
box(-0.08, FL+0.08+0.90, ZN, 0.00, FL+0.08+1.04, ZN-0.03)
box( 0.00, FL+0.08+0.90, ZN, 0.08, FL+0.08+1.04, ZN-0.03)
// Sill
box(-0.74, FL+0.06, ZN-0.03, 0.74, FL+0.10, ZN)
}
// ── RAILING ────────────────────────────────────────────────────
const GAP = 0.88 // half-width of step opening
// Corner posts
for (const [px,pz] of [[-TX,ZN],[TX,ZN],[-TX,ZF],[TX,ZF]])
box(px-0.05, FL, pz-0.05, px+0.05, RH, pz+0.05)
// Front posts (left and right of step gap)
for (let x = -TX+0.78; x < -GAP; x += 0.78)
box(x-0.04, FL, ZF-0.04, x+0.04, RH, ZF+0.04)
for (let x = GAP+0.40; x < TX-0.30; x += 0.78)
box(x-0.04, FL, ZF-0.04, x+0.04, RH, ZF+0.04)
// Side posts
for (let z = ZN+0.80; z < ZF-0.30; z += 0.80) {
box(-TX-0.04, FL, z-0.04, -TX+0.04, RH, z+0.04)
box( TX-0.04, FL, z-0.04, TX+0.04, RH, z+0.04)
}
// Top rails
line(-TX, RH, ZN, -TX, RH, ZF)
line( TX, RH, ZN, TX, RH, ZF)
line(-TX, RH, ZF, -GAP, RH, ZF)
line( GAP, RH, ZF, TX, RH, ZF)
// Mid rails
line(-TX, FL+0.52, ZN, -TX, FL+0.52, ZF)
line( TX, FL+0.52, ZN, TX, FL+0.52, ZF)
line(-TX, FL+0.52, ZF, -GAP, FL+0.52, ZF)
line( GAP, FL+0.52, ZF, TX, FL+0.52, ZF)
// Bottom rails
line(-TX, FL+0.12, ZN, -TX, FL+0.12, ZF)
line( TX, FL+0.12, ZN, TX, FL+0.12, ZF)
line(-TX, FL+0.12, ZF, -GAP, FL+0.12, ZF)
line( GAP, FL+0.12, ZF, TX, FL+0.12, ZF)
// Balusters — sides
for (let z = ZN+0.22; z < ZF; z += 0.22) {
line(-TX, FL+0.14, z, -TX, RH-0.04, z)
line( TX, FL+0.14, z, TX, RH-0.04, z)
}
// Balusters — front left
for (let x = -TX+0.22; x < -GAP; x += 0.22)
line(x, FL+0.14, ZF, x, RH-0.04, ZF)
// Balusters — front right
for (let x = GAP+0.22; x < TX; x += 0.22)
line(x, FL+0.14, ZF, x, RH-0.04, ZF)
// ── STEPS ──────────────────────────────────────────────────────
box(-GAP, FL-0.18, ZF, GAP, FL, ZF+0.38)
box(-GAP+0.10, FL-0.36, ZF+0.38, GAP-0.10, FL-0.18, ZF+0.76)
box(-GAP+0.20, FL-0.54, ZF+0.76, GAP-0.20, FL-0.36, ZF+1.14)
// ── PERGOLA ────────────────────────────────────────────────────
const PH = FL + 2.28
// 4 posts
for (const [px,pz] of [[-TX+0.10,ZN+0.24],[TX-0.10,ZN+0.24],[-TX+0.10,ZF-0.30],[TX-0.10,ZF-0.30]])
box(px-0.07, FL, pz-0.07, px+0.07, PH, pz+0.07)
// Longitudinal beams (z direction, on top of posts)
box(-TX+0.04, PH, ZN+0.24, -TX+0.16, PH+0.14, ZF-0.30)
box( TX-0.16, PH, ZN+0.24, TX-0.04, PH+0.14, ZF-0.30)
// Cross beams (x direction, resting on longitudinal beams)
for (let z = ZN+0.50; z < ZF-0.20; z += 0.80)
box(-TX+0.04, PH+0.14, z-0.05, TX-0.04, PH+0.20, z+0.05)
// ── TABLE ──────────────────────────────────────────────────────
box(-0.68, FL+0.76, -0.72, 0.68, FL+0.80, 0.72)
// Board lines on table top
for (let tz = -0.60; tz <= 0.60; tz += 0.24)
line(-0.66, FL+0.80, tz, 0.66, FL+0.80, tz)
// Legs
for (const [lx,lz] of [[-0.60,-0.62],[0.60,-0.62],[-0.60,0.62],[0.60,0.62]])
box(lx-0.04, FL, lz-0.04, lx+0.04, FL+0.76, lz+0.04)
// ── CHAIRS ─────────────────────────────────────────────────────
// [cx, cz, bx, bz] — bx/bz offset for backrest (away from table)
for (const [cx, cz, bx, bz] of [
[ 0.00, -1.10, 0.00, -0.21],
[ 0.00, 1.10, 0.00, 0.21],
[-1.06, 0.00, -0.21, 0.00],
[ 1.06, 0.00, 0.21, 0.00],
]) {
box(cx-0.21, FL+0.46, cz-0.21, cx+0.21, FL+0.50, cz+0.21)
for (const [dx,dz] of [[-0.16,-0.16],[0.16,-0.16],[-0.16,0.16],[0.16,0.16]])
box(cx+dx-0.03, FL, cz+dz-0.03, cx+dx+0.03, FL+0.46, cz+dz+0.03)
if (bz !== 0) {
const bsz = cz + bz
box(cx-0.21, FL+0.50, bsz-0.04, cx+0.21, FL+0.90, bsz+0.04)
line(cx-0.19, FL+0.68, bsz, cx+0.19, FL+0.68, bsz)
} else {
const bsx = cx + bx
box(bsx-0.04, FL+0.50, cz-0.21, bsx+0.04, FL+0.90, cz+0.21)
line(bsx, FL+0.68, cz-0.19, bsx, FL+0.68, cz+0.19)
}
}
// ── PLANTERS (corners near house wall) ─────────────────────────
for (const [px,pz] of [[-TX+0.32, ZN+0.34],[TX-0.32, ZN+0.34]]) {
box(px-0.28, FL, pz-0.28, px+0.28, FL+0.52, pz+0.28)
box(px-0.26, FL+0.48, pz-0.26, px+0.26, FL+0.52, pz+0.26)
cylinder(px, FL+0.52, pz, 0.06, 0.06, FL+0.88, 8)
ring(px, FL+0.88, pz, 0.22, 0.22, 14)
ring(px, FL+0.74, pz, 0.14, 0.14, 10)
for (let i = 0; i < 6; i++) {
const a = (i / 6) * Math.PI * 2
line(px, FL+0.88, pz, px + 0.14*Math.cos(a), FL+1.06, pz + 0.14*Math.sin(a))
}
}
// ── WALL LAMP (beside door) ────────────────────────────────────
box(-0.88, FL+1.52, ZN-0.03, -0.74, FL+1.56, ZN)
box(-0.88, FL+1.56, ZN-0.14, -0.74, FL+1.74, ZN)
ring(-0.81, FL+1.65, ZN-0.07, 0.07, 0.07, 8)
ring(-0.81, FL+1.74, ZN-0.07, 0.04, 0.04, 6)
// ── BARBECUE / GRILL (side wall) ───────────────────────────────
box(TX-0.58, FL, ZN+0.70, TX-0.04, FL+0.92, ZN+1.20)
box(TX-0.60, FL+0.88, ZN+0.68, TX-0.02, FL+0.94, ZN+1.22) // lid rim
box(TX-0.60, FL+0.94, ZN+0.68, TX-0.02, FL+1.02, ZN+1.22) // lid dome
// Side shelf
box(TX-0.58, FL+0.78, ZN+1.20, TX-0.04, FL+0.84, ZN+1.52)
// Legs
for (const [lx,lz] of [[TX-0.50,ZN+0.76],[TX-0.12,ZN+0.76],[TX-0.12,ZN+1.16]])
box(lx-0.03, FL-0.06, lz-0.03, lx+0.03, FL, lz+0.03)
return { verts: V, edges: E }
}
const { verts, edges } = buildTerrace()
// =================================================================
// ENGINE
// =================================================================
export default function TerraceViewer({ onClose }) {
const canvasRef = useRef(null)
const [ready, setReady] = useState(false)
const stateRef = useRef({
rotX: 0.36, rotY: -0.46,
dragging: false, lastX: 0, lastY: 0,
autoRotate: true, idleTimer: null,
zoom: 0.52, rafId: null, lastTs: 0,
})
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
const state = stateRef.current
const resize = () => {
const dpr = Math.min(window.devicePixelRatio || 1, 2)
canvas.width = canvas.offsetWidth * dpr
canvas.height = canvas.offsetHeight * dpr
}
resize()
const ro = new ResizeObserver(resize)
ro.observe(canvas)
function project(v) {
let [x, y, z] = v
const { rotX, rotY, zoom } = state
const dpr = Math.min(window.devicePixelRatio || 1, 2)
const W = canvas.width / dpr
const H = canvas.height / dpr
const cy_ = Math.cos(rotY), sy_ = Math.sin(rotY)
const x1 = x * cy_ - z * sy_
const z1 = x * sy_ + z * cy_
const cx_ = Math.cos(rotX), sx_ = Math.sin(rotX)
const y2 = y * cx_ - z1 * sx_
const z2 = y * sx_ + z1 * cx_
const fov = Math.min(W, H) * 1.75
const scale = Math.min(W, H) * 0.095 * zoom
const d = fov + z2
return { x: (x1/d)*fov*scale + W/2, y: (-y2/d)*fov*scale + H/2, z: z2 }
}
function render() {
const dpr = Math.min(window.devicePixelRatio || 1, 2)
const W = canvas.width / dpr
const H = canvas.height / dpr
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, W, H)
if (verts.length === 0) return
const proj = verts.map(v => project(v))
let zMin = Infinity, zMax = -Infinity
for (const p of proj) { if (p.z < zMin) zMin = p.z; if (p.z > zMax) zMax = p.z }
const zRange = zMax - zMin || 1
const sorted = edges
.map(([a, b]) => ({ a, b, depth: (proj[a].z + proj[b].z) * 0.5 }))
.sort((e1, e2) => e2.depth - e1.depth)
for (const { a, b, depth } of sorted) {
const pa = proj[a], pb = proj[b]
const t = (depth - zMin) / zRange
const da = Math.max(0.10, Math.min(1.0, 1.05 - t * 0.88))
ctx.beginPath(); ctx.moveTo(pa.x, pa.y); ctx.lineTo(pb.x, pb.y)
ctx.strokeStyle = `rgba(190,110,255,${da})`
ctx.lineWidth = 1.3; ctx.stroke()
}
for (const p of proj) {
const t = (p.z - zMin) / zRange
const da = Math.max(0.08, 0.45 - t * 0.35)
ctx.beginPath(); ctx.arc(p.x, p.y, 1.4, 0, Math.PI * 2)
ctx.fillStyle = `rgba(220,170,255,${da})`; ctx.fill()
}
}
let firstFrame = true
function loop(ts) {
state.rafId = requestAnimationFrame(loop)
if (ts - state.lastTs < 32) return
state.lastTs = ts
if (state.autoRotate && !state.dragging) state.rotY += 0.006
render()
if (firstFrame) { firstFrame = false; setReady(true) }
}
state.rafId = requestAnimationFrame(loop)
const stopAutoRotate = () => {
state.autoRotate = false
clearTimeout(state.idleTimer)
state.idleTimer = setTimeout(() => { state.autoRotate = true }, 2200)
}
const onMouseDown = (e) => { state.dragging=true; state.lastX=e.clientX; state.lastY=e.clientY; stopAutoRotate() }
const onMouseMove = (e) => {
if (!state.dragging) return
state.rotY += (e.clientX-state.lastX)*0.008; state.rotX += (e.clientY-state.lastY)*0.008
state.rotX = Math.max(-0.75,Math.min(0.75,state.rotX)); state.lastX=e.clientX; state.lastY=e.clientY
}
const onMouseUp = () => { state.dragging=false }
const onWheel = (e) => {
e.preventDefault(); state.zoom *= e.deltaY>0 ? 0.93 : 1.07
state.zoom = Math.max(0.30,Math.min(3.0,state.zoom)); stopAutoRotate()
}
let lastTX=0, lastTY=0, lastPinchDist=0
const onTouchStart = (e) => {
stopAutoRotate(); state.dragging=true
if (e.touches.length===1) { lastTX=e.touches[0].clientX; lastTY=e.touches[0].clientY }
else if (e.touches.length===2) lastPinchDist=Math.hypot(e.touches[0].clientX-e.touches[1].clientX,e.touches[0].clientY-e.touches[1].clientY)
}
const onTouchMove = (e) => {
if (!state.dragging) return
if (e.touches.length===1) {
state.rotY+=(e.touches[0].clientX-lastTX)*0.008; state.rotX+=(e.touches[0].clientY-lastTY)*0.008
state.rotX=Math.max(-0.75,Math.min(0.75,state.rotX)); lastTX=e.touches[0].clientX; lastTY=e.touches[0].clientY
} else if (e.touches.length===2) {
const dist=Math.hypot(e.touches[0].clientX-e.touches[1].clientX,e.touches[0].clientY-e.touches[1].clientY)
if (lastPinchDist) state.zoom*=dist/lastPinchDist
state.zoom=Math.max(0.30,Math.min(3.0,state.zoom)); lastPinchDist=dist
}
}
const onTouchEnd = () => { state.dragging=false; lastPinchDist=0 }
canvas.addEventListener('mousedown', onMouseDown)
window.addEventListener('mousemove', onMouseMove)
window.addEventListener('mouseup', onMouseUp)
canvas.addEventListener('wheel', onWheel, { passive: false })
canvas.addEventListener('touchstart', onTouchStart, { passive: true })
canvas.addEventListener('touchmove', onTouchMove, { passive: true })
canvas.addEventListener('touchend', onTouchEnd)
const onKeyDown = (e) => { if (e.key==='Escape') onClose() }
window.addEventListener('keydown', onKeyDown)
return () => {
cancelAnimationFrame(state.rafId); clearTimeout(state.idleTimer); ro.disconnect(); setReady(false)
canvas.removeEventListener('mousedown', onMouseDown); window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('mouseup', onMouseUp); canvas.removeEventListener('wheel', onWheel)
canvas.removeEventListener('touchstart', onTouchStart);canvas.removeEventListener('touchmove', onTouchMove)
canvas.removeEventListener('touchend', onTouchEnd); window.removeEventListener('keydown', onKeyDown)
}
}, [onClose])
return (
<div className="trv-overlay" onClick={onClose}>
<div className="trv-modal" onClick={e => e.stopPropagation()}>
<div className="trv-header">
<div className="trv-title-group">
<span className="trv-label">3D Modell</span>
<h3 className="trv-title">Terrasse</h3>
</div>
<button className="trv-close" onClick={onClose} aria-label="Schließen">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
</svg>
</button>
</div>
<div className="trv-canvas-wrap">
<canvas ref={canvasRef} className={`trv-canvas${ready ? ' trv-canvas--ready' : ''}`} />
{!ready && (
<div className="trv-loader" aria-label="Wird geladen">
<div className="trv-loader-ring" />
<span className="trv-loader-text">Modell wird geladen</span>
</div>
)}
</div>
<div className="trv-footer">
<span className="trv-hint">Ziehen zum Drehen</span>
<span className="trv-hint">Scrollen zum Zoomen</span>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,152 @@
.wwm {
background: rgba(18, 18, 18, 0.68);
border-top: 1px solid var(--color-border);
}
.wwm-header {
margin-bottom: var(--space-12);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.wwm-label {
font-size: var(--font-size-xs);
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--color-accent);
font-weight: 600;
}
.wwm-title {
font-size: clamp(1.75rem, 3vw, 2.5rem);
font-weight: 700;
letter-spacing: -0.02em;
color: var(--color-text);
}
.wwm-subtitle {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
max-width: 480px;
}
.wwm-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-4);
}
.wwm-tile {
position: relative;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--space-3);
padding: var(--space-6);
background: var(--color-surface-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
cursor: pointer;
text-align: left;
transition: border-color var(--transition), background var(--transition), box-shadow var(--transition);
overflow: hidden;
}
.wwm-tile:hover {
border-color: var(--color-accent-border);
background: var(--color-surface-3);
}
/* 3D tile — subtle extra glow to hint interactivity */
.wwm-tile--3d {
border-color: rgba(139, 61, 184, 0.35);
}
.wwm-tile--3d:hover {
border-color: var(--color-accent);
box-shadow: 0 0 20px rgba(139, 61, 184, 0.20);
}
.wwm-tile--3d .wwm-tile-arrow {
color: var(--color-accent);
}
.wwm-tile--active {
border-color: var(--color-accent) !important;
background: var(--color-accent-dim) !important;
box-shadow: 0 0 0 1px var(--color-accent), var(--shadow-accent);
}
.wwm-tile-icon {
color: var(--color-accent);
transition: color var(--transition);
flex-shrink: 0;
}
.wwm-tile-icon svg {
width: 40px;
height: 40px;
}
.wwm-tile--active .wwm-tile-icon {
color: #C06EE8;
}
.wwm-tile-title {
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--color-text);
line-height: 1.3;
}
.wwm-tile-desc {
font-size: var(--font-size-xs);
color: var(--color-text-muted);
line-height: 1.6;
max-height: 0;
overflow: hidden;
opacity: 0;
transition: max-height var(--transition-slow), opacity var(--transition-slow);
}
.wwm-tile-desc--visible {
max-height: 120px;
opacity: 1;
}
.wwm-tile-arrow {
position: absolute;
top: var(--space-4);
right: var(--space-4);
color: var(--color-text-faint);
transition: transform var(--transition), color var(--transition);
}
.wwm-tile-arrow svg {
width: 16px;
height: 16px;
}
.wwm-tile--active .wwm-tile-arrow {
transform: rotate(180deg);
color: var(--color-accent);
}
@media (max-width: 1024px) {
.wwm-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 768px) {
.wwm-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 480px) {
.wwm-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,204 @@
import { useState, lazy, Suspense } from 'react'
const StairViewer = lazy(() => import('../StairViewer/StairViewer'))
const BathViewer = lazy(() => import('../BathViewer/BathViewer'))
const KitchenViewer = lazy(() => import('../KitchenViewer/KitchenViewer'))
const RoomViewer = lazy(() => import('../RoomViewer/RoomViewer'))
const BedroomViewer = lazy(() => import('../BedroomViewer/BedroomViewer'))
const TerraceViewer = lazy(() => import('../TerraceViewer/TerraceViewer'))
const ClosetViewer = lazy(() => import('../ClosetViewer/ClosetViewer'))
import './WhatWeMeasure.css'
const SERVICES = [
{
id: 'kuche',
title: 'Küche',
description: 'Komplette Raumerfassung inkl. Anschlüsse, Vorsprünge, Fensterlaibungen und Bodenneigung.',
icon: (
<svg viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="4" y="22" width="32" height="14" rx="2" stroke="currentColor" strokeWidth="1.5" />
<rect x="4" y="4" width="32" height="14" rx="2" stroke="currentColor" strokeWidth="1.5" />
<circle cx="14" cy="11" r="3" stroke="currentColor" strokeWidth="1.5" />
<circle cx="26" cy="11" r="3" stroke="currentColor" strokeWidth="1.5" />
<rect x="10" y="26" width="8" height="6" rx="1" stroke="currentColor" strokeWidth="1.5" />
</svg>
),
},
{
id: 'raum',
title: 'Raum / Grundriss',
description: 'Einzelräume oder ganze Etagen. Wände, Öffnungen, Höhen, Winkel alles in einer Messung.',
icon: (
<svg viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 34V10l10-6 18 6v24H6z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
<path d="M6 34h28" stroke="currentColor" strokeWidth="1.5" />
<path d="M16 34V22h8v12" stroke="currentColor" strokeWidth="1.5" />
<path d="M6 10h28" stroke="currentColor" strokeWidth="1.5" strokeDasharray="2 2" />
</svg>
),
},
{
id: 'treppe',
title: 'Treppe',
description: 'Lauf, Podest, Wangen, lichte Maße. Auch gewendelte und freitragende Treppen.',
icon: (
<svg viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 34h6V28h6V22h6V16h6V10h4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M6 34V10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
),
},
{
id: 'schrank',
title: 'Schrank / Einbaumöbel',
description: 'Nischen, Dachschrägen, unebene Wände. Exakte Maße für passgenaue Fertigung.',
icon: (
<svg viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="4" y="6" width="32" height="30" rx="2" stroke="currentColor" strokeWidth="1.5" />
<line x1="20" y1="6" x2="20" y2="36" stroke="currentColor" strokeWidth="1.5" />
<circle cx="17" cy="21" r="1.5" fill="currentColor" />
<circle cx="23" cy="21" r="1.5" fill="currentColor" />
<line x1="4" y1="14" x2="36" y2="14" stroke="currentColor" strokeWidth="1.5" />
</svg>
),
},
{
id: 'bad',
title: 'Bad',
description: 'Raum mit allen Installationspunkten, Anschlüssen, Gefälle und Fliesenkanten.',
icon: (
<svg viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 20h24v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-8z" stroke="currentColor" strokeWidth="1.5" />
<path d="M8 20V10a4 4 0 014-4h1a3 3 0 013 3v11" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<line x1="16" y1="34" x2="14" y2="38" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<line x1="24" y1="34" x2="26" y2="38" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
),
},
{
id: 'schlafzimmer',
title: 'Schlafzimmer',
description: 'Bett, Einbauschränke, Nischen, Dachschrägen präzise Maße für Möbel und Renovierung.',
icon: (
<svg viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="4" y="18" width="32" height="14" rx="2" stroke="currentColor" strokeWidth="1.5" />
<path d="M4 22h32" stroke="currentColor" strokeWidth="1.5" />
<rect x="6" y="22" width="10" height="6" rx="1" stroke="currentColor" strokeWidth="1.5" />
<rect x="24" y="22" width="10" height="6" rx="1" stroke="currentColor" strokeWidth="1.5" />
<path d="M4 18v-4a2 2 0 012-2h28a2 2 0 012 2v4" stroke="currentColor" strokeWidth="1.5" />
</svg>
),
},
{
id: 'terrasse',
title: 'Terrasse',
description: 'Bodenbelag, Geländer, Stufen und Entwässerung exaktes Aufmaß für Neugestaltung und Planung.',
icon: (
<svg viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="4" y="10" width="32" height="18" stroke="currentColor" strokeWidth="1.5" />
<line x1="4" y1="10" x2="4" y2="28" stroke="currentColor" strokeWidth="1.5" />
<line x1="36" y1="10" x2="36" y2="28" stroke="currentColor" strokeWidth="1.5" />
<rect x="13" y="28" width="14" height="3" stroke="currentColor" strokeWidth="1.5" />
<rect x="15" y="31" width="10" height="3" stroke="currentColor" strokeWidth="1.5" />
<line x1="4" y1="18" x2="36" y2="18" stroke="currentColor" strokeWidth="1.5" strokeOpacity="0.55" />
<line x1="4" y1="24" x2="36" y2="24" stroke="currentColor" strokeWidth="1.5" strokeOpacity="0.55" />
<line x1="17" y1="10" x2="17" y2="28" stroke="currentColor" strokeWidth="1.5" strokeOpacity="0.55" />
<line x1="26" y1="10" x2="26" y2="28" stroke="currentColor" strokeWidth="1.5" strokeOpacity="0.55" />
</svg>
),
},
{
id: 'individuell',
title: 'Individuell',
description: 'Ihr Projekt passt in keine Kategorie? Sprechen Sie uns an.',
icon: (
<svg viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="20" cy="20" r="14" stroke="currentColor" strokeWidth="1.5" />
<line x1="20" y1="13" x2="20" y2="27" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<line x1="13" y1="20" x2="27" y2="20" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
),
},
]
export default function WhatWeMeasure() {
const [activeId, setActiveId] = useState(null)
const [showStair, setShowStair] = useState(false)
const [showBath, setShowBath] = useState(false)
const [showKitchen, setShowKitchen] = useState(false)
const [showRoom, setShowRoom] = useState(false)
const [showBedroom, setShowBedroom] = useState(false)
const [showTerrace, setShowTerrace] = useState(false)
const [showCloset, setShowCloset] = useState(false)
const handleTileClick = (id) => {
if (id === 'treppe') { setShowStair(true); return }
if (id === 'bad') { setShowBath(true); return }
if (id === 'kuche') { setShowKitchen(true); return }
if (id === 'raum') { setShowRoom(true); return }
if (id === 'schlafzimmer') { setShowBedroom(true); return }
if (id === 'terrasse') { setShowTerrace(true); return }
if (id === 'schrank') { setShowCloset(true); return }
setActiveId(activeId === id ? null : id)
}
return (
<div className="wwm section">
<div className="container">
<div className="wwm-header">
<span className="wwm-label">Aufmaßbereiche</span>
<h2 className="wwm-title">Was wir aufmessen.</h2>
<p className="wwm-subtitle">
Wählen Sie eine Kategorie für mehr Details oder gehen Sie direkt zum Kostenrechner.
</p>
</div>
<div className="wwm-grid">
{SERVICES.map((service) => {
const isActive = activeId === service.id
const is3D = ['treppe','bad','kuche','raum','schlafzimmer','terrasse','schrank'].includes(service.id)
return (
<button
key={service.id}
className={`wwm-tile${isActive ? ' wwm-tile--active' : ''}${is3D ? ' wwm-tile--3d' : ''}`}
onClick={() => handleTileClick(service.id)}
aria-expanded={isActive}
title={is3D ? '3D Modell anzeigen' : undefined}
>
<div className="wwm-tile-icon">{service.icon}</div>
<div className="wwm-tile-title">{service.title}</div>
<div className={`wwm-tile-desc${isActive ? ' wwm-tile-desc--visible' : ''}`}>
{service.description}
</div>
<div className="wwm-tile-arrow" aria-hidden="true">
{is3D ? (
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 2l4 2.5v5L8 12 4 9.5v-5L8 2z" stroke="currentColor" strokeWidth="1.2" strokeLinejoin="round"/>
<path d="M8 2v10M4 4.5l4 2.5 4-2.5" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round"/>
</svg>
) : (
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 6l4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</div>
</button>
)
})}
</div>
</div>
<Suspense fallback={null}>
{showStair && <StairViewer onClose={() => setShowStair(false)} />}
{showBath && <BathViewer onClose={() => setShowBath(false)} />}
{showKitchen && <KitchenViewer onClose={() => setShowKitchen(false)} />}
{showRoom && <RoomViewer onClose={() => setShowRoom(false)} />}
{showBedroom && <BedroomViewer onClose={() => setShowBedroom(false)} />}
{showTerrace && <TerraceViewer onClose={() => setShowTerrace(false)} />}
{showCloset && <ClosetViewer onClose={() => setShowCloset(false)} />}
</Suspense>
</div>
)
}
export { SERVICES }

View File

@@ -0,0 +1,109 @@
.why {
background: rgba(18, 18, 18, 0.68);
border-top: 1px solid var(--color-border);
}
.why-header {
margin-bottom: var(--space-12);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.why-label {
font-size: var(--font-size-xs);
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--color-accent);
font-weight: 600;
}
.why-title {
font-size: clamp(1.75rem, 3vw, 2.5rem);
font-weight: 700;
letter-spacing: -0.02em;
color: var(--color-text);
line-height: 1.2;
}
.why-lead {
font-size: var(--font-size-md);
color: var(--color-text-muted);
max-width: 560px;
line-height: 1.65;
}
.why-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-5);
}
.why-card {
padding: var(--space-8) var(--space-6);
background: var(--color-surface-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
display: flex;
flex-direction: column;
gap: var(--space-4);
transition: border-color var(--transition);
}
.why-card:hover {
border-color: var(--color-accent-border);
}
.why-icon {
color: var(--color-accent);
}
.why-icon svg {
width: 32px;
height: 32px;
}
.why-card-title {
font-size: var(--font-size-base);
font-weight: 700;
color: var(--color-text);
line-height: 1.3;
}
.why-card-body {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
line-height: 1.65;
flex: 1;
}
.why-formats {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-top: var(--space-2);
}
.why-format-badge {
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.08em;
color: var(--color-accent);
background: var(--color-accent-dim);
border: 1px solid var(--color-accent-border);
border-radius: var(--radius-sm);
padding: 0.2rem 0.55rem;
}
@media (max-width: 1024px) {
.why-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 640px) {
.why-grid {
grid-template-columns: 1fr;
max-width: 480px;
}
}

View File

@@ -0,0 +1,93 @@
import './WhySuperfice.css'
const POINTS = [
{
icon: (
<svg viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16 4l2.5 7.5H26l-6.5 4.5 2.5 7.5L16 19l-6 4.5 2.5-7.5L6 11.5h7.5L16 4z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
</svg>
),
title: 'Keine Zahlendreher.',
body: 'Digitale Messdaten keine handschriftlichen Notizen, keine Übertragungsfehler. Jede Messung wird zweifach geprüft.',
},
{
icon: (
<svg viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="4" y="6" width="24" height="20" rx="2" stroke="currentColor" strokeWidth="1.5" />
<path d="M4 12h24" stroke="currentColor" strokeWidth="1.5" />
<path d="M10 18h4M10 22h8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
),
title: 'Keine Handskizzen.',
body: 'Fertige CAD-Datei, direkt importierbar ohne Zeichenaufwand oder Nachbearbeitung auf Ihrer Seite.',
},
{
icon: (
<svg viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="16" cy="16" r="11" stroke="currentColor" strokeWidth="1.5" />
<path d="M16 10v6l4 2" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
),
title: 'Kein zweites Mal hinfahren.',
body: 'Alles in einem Termin. Komplex, möbliert, Altbau oder Dachschräge kein Objekt ist zu kompliziert.',
},
{
icon: (
<svg viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 20l10-10 4 4 6-8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M22 8h4v4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
),
title: 'Ihr Format.',
body: 'Wir liefern in dem Format, das Ihre Software braucht ohne Umformatierung oder manuelle Anpassung.',
formats: ['DXF', 'DWG', 'RVT', 'IFC', 'PDF'],
},
{
icon: (
<svg viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 26V14l10-8 10 8v12" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
<rect x="12" y="20" width="8" height="6" rx="1" stroke="currentColor" strokeWidth="1.5" />
<path d="M10 14h12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
),
title: 'Für jeden Betrieb.',
body: 'Ob Einzelauftrag oder laufende Zusammenarbeit Superfice skaliert mit Ihrem Auftragsvolumen.',
},
]
export default function WhySuperfice() {
return (
<div className="why section">
<div className="container">
<div className="why-header">
<span className="why-label">Warum Superfice</span>
<h2 className="why-title">
Geprüft. Millimetergenau.
<br />Sofort nutzbar.
</h2>
<p className="why-lead">
Sie bekommen ein geprüftes, millimetergenaues Aufmaß im Format Ihrer Wahl
und können sofort weiterarbeiten. Kein Mehraufwand, kein Risiko.
</p>
</div>
<div className="why-grid">
{POINTS.map((point, i) => (
<div key={i} className="why-card">
<div className="why-icon">{point.icon}</div>
<h3 className="why-card-title">{point.title}</h3>
<p className="why-card-body">{point.body}</p>
{point.formats && (
<div className="why-formats">
{point.formats.map((f) => (
<span key={f} className="why-format-badge">{f}</span>
))}
</div>
)}
</div>
))}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,162 @@
.wv-overlay {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(6, 6, 10, 0.82);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
animation: wv-fade-in 0.22s ease;
}
@keyframes wv-fade-in {
from { opacity: 0 }
to { opacity: 1 }
}
.wv-modal {
width: 100%;
max-width: 640px;
height: min(580px, 90vh);
background: rgba(14, 14, 22, 0.96);
border: 1px solid rgba(139, 61, 184, 0.40);
border-radius: 16px;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow:
0 0 0 1px rgba(139, 61, 184, 0.12),
0 24px 80px rgba(0, 0, 0, 0.65),
0 0 60px rgba(139, 61, 184, 0.12);
animation: wv-slide-up 0.28s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes wv-slide-up {
from { opacity: 0; transform: translateY(24px) scale(0.97) }
to { opacity: 1; transform: translateY(0) scale(1) }
}
.wv-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 1.5rem 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
flex-shrink: 0;
}
.wv-title-group {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.wv-label {
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--color-accent);
}
.wv-title {
font-size: 1.1rem;
font-weight: 700;
color: var(--color-text);
letter-spacing: -0.01em;
}
.wv-close {
width: 32px;
height: 32px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.10);
color: var(--color-text-muted);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background var(--transition), color var(--transition), border-color var(--transition);
flex-shrink: 0;
}
.wv-close svg { width: 14px; height: 14px; }
.wv-close:hover {
background: rgba(139, 61, 184, 0.22);
border-color: rgba(139, 61, 184, 0.50);
color: var(--color-text);
}
.wv-canvas-wrap {
flex: 1;
position: relative;
overflow: hidden;
}
.wv-canvas {
width: 100%;
height: 100%;
display: block;
cursor: grab;
opacity: 0;
transition: opacity 0.35s ease;
}
.wv-canvas--ready { opacity: 1; }
.wv-canvas:active { cursor: grabbing; }
.wv-loader {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
pointer-events: none;
}
.wv-loader-ring {
width: 40px;
height: 40px;
border-radius: 50%;
border: 2px solid rgba(139, 61, 184, 0.18);
border-top-color: var(--color-accent);
animation: wv-spin 0.85s linear infinite;
}
@keyframes wv-spin { to { transform: rotate(360deg); } }
.wv-loader-text {
font-size: 0.72rem;
letter-spacing: 0.10em;
text-transform: uppercase;
color: var(--color-text-faint);
animation: wv-pulse 1.6s ease-in-out infinite;
}
@keyframes wv-pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 0.9; }
}
.wv-footer {
display: flex;
align-items: center;
justify-content: center;
gap: 1.5rem;
padding: 0.75rem 1.5rem;
border-top: 1px solid rgba(255, 255, 255, 0.07);
flex-shrink: 0;
}
.wv-hint {
font-size: 0.72rem;
color: var(--color-text-faint);
letter-spacing: 0.03em;
}

View File

@@ -0,0 +1,275 @@
import { useEffect, useRef, useState } from 'react'
import './WireframeViewer.css'
// ─────────────────────────────────────────────────────────────────
// YOUR GEOMETRY GOES HERE
//
// verts → array of points in 3D space: [x, y, z]
// edges → array of lines between two vertex indices: [from, to]
//
// Coordinate system:
// X → right
// Y → up
// Z → toward you (out of screen)
//
// Keep values roughly in the -2 … +2 range so the model fits nicely.
// Center your model around [0, 0, 0].
//
// Example below: a simple cube.
// ─────────────────────────────────────────────────────────────────
const verts = [
// [ x, y, z ]
[ -1, -1, -1 ], // 0
[ 1, -1, -1 ], // 1
[ 1, 1, -1 ], // 2
[ -1, 1, -1 ], // 3
[ -1, -1, 1 ], // 4
[ 1, -1, 1 ], // 5
[ 1, 1, 1 ], // 6
[ -1, 1, 1 ], // 7
]
const edges = [
[ 0, 1 ], [ 1, 2 ], [ 2, 3 ], [ 3, 0 ], // back face
[ 4, 5 ], [ 5, 6 ], [ 6, 7 ], [ 7, 4 ], // front face
[ 0, 4 ], [ 1, 5 ], [ 2, 6 ], [ 3, 7 ], // connecting edges
]
// ─────────────────────────────────────────────────────────────────
// ENGINE — no need to touch anything below this line
// ─────────────────────────────────────────────────────────────────
export default function WireframeViewer({ onClose }) {
const canvasRef = useRef(null)
const [ready, setReady] = useState(false)
const stateRef = useRef({
rotX: 0.30, rotY: -0.55,
dragging: false, lastX: 0, lastY: 0,
autoRotate: true, idleTimer: null,
zoom: 1.0, rafId: null, lastTs: 0,
})
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
const state = stateRef.current
const resize = () => {
const dpr = Math.min(window.devicePixelRatio || 1, 2)
canvas.width = canvas.offsetWidth * dpr
canvas.height = canvas.offsetHeight * dpr
}
resize()
const ro = new ResizeObserver(resize)
ro.observe(canvas)
function project(v) {
let [x, y, z] = v
const { rotX, rotY, zoom } = state
const dpr = Math.min(window.devicePixelRatio || 1, 2)
const W = canvas.width / dpr
const H = canvas.height / dpr
const cy_ = Math.cos(rotY), sy_ = Math.sin(rotY)
const x1 = x * cy_ - z * sy_
const z1 = x * sy_ + z * cy_
const cx_ = Math.cos(rotX), sx_ = Math.sin(rotX)
const y2 = y * cx_ - z1 * sx_
const z2 = y * sx_ + z1 * cx_
const fov = Math.min(W, H) * 1.75
const scale = Math.min(W, H) * 0.095 * zoom
const d = fov + z2
return {
x: (x1 / d) * fov * scale + W / 2,
y: (-y2 / d) * fov * scale + H / 2,
z: z2,
}
}
function render() {
const dpr = Math.min(window.devicePixelRatio || 1, 2)
const W = canvas.width / dpr
const H = canvas.height / dpr
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, W, H)
const proj = verts.map(v => project(v))
let zMin = Infinity, zMax = -Infinity
for (const p of proj) {
if (p.z < zMin) zMin = p.z
if (p.z > zMax) zMax = p.z
}
const zRange = zMax - zMin || 1
const sorted = edges
.map(([a, b]) => ({ a, b, depth: (proj[a].z + proj[b].z) * 0.5 }))
.sort((e1, e2) => e2.depth - e1.depth)
for (const { a, b, depth } of sorted) {
const pa = proj[a]
const pb = proj[b]
const t = (depth - zMin) / zRange
const da = Math.max(0.10, Math.min(1.0, 1.05 - t * 0.88))
ctx.beginPath()
ctx.moveTo(pa.x, pa.y)
ctx.lineTo(pb.x, pb.y)
ctx.strokeStyle = `rgba(190,110,255,${da})`
ctx.lineWidth = 1.3
ctx.stroke()
}
for (const p of proj) {
const t = (p.z - zMin) / zRange
const da = Math.max(0.08, 0.45 - t * 0.35)
ctx.beginPath()
ctx.arc(p.x, p.y, 1.4, 0, Math.PI * 2)
ctx.fillStyle = `rgba(220,170,255,${da})`
ctx.fill()
}
}
let firstFrame = true
function loop(ts) {
state.rafId = requestAnimationFrame(loop)
if (ts - state.lastTs < 32) return
state.lastTs = ts
if (state.autoRotate && !state.dragging) state.rotY += 0.006
render()
if (firstFrame) { firstFrame = false; setReady(true) }
}
state.rafId = requestAnimationFrame(loop)
const stopAutoRotate = () => {
state.autoRotate = false
clearTimeout(state.idleTimer)
state.idleTimer = setTimeout(() => { state.autoRotate = true }, 2200)
}
const onMouseDown = (e) => {
state.dragging = true
state.lastX = e.clientX
state.lastY = e.clientY
stopAutoRotate()
}
const onMouseMove = (e) => {
if (!state.dragging) return
state.rotY += (e.clientX - state.lastX) * 0.008
state.rotX += (e.clientY - state.lastY) * 0.008
state.rotX = Math.max(-0.75, Math.min(0.75, state.rotX))
state.lastX = e.clientX
state.lastY = e.clientY
}
const onMouseUp = () => { state.dragging = false }
const onWheel = (e) => {
e.preventDefault()
state.zoom *= e.deltaY > 0 ? 0.93 : 1.07
state.zoom = Math.max(0.35, Math.min(3.0, state.zoom))
stopAutoRotate()
}
let lastTX = 0, lastTY = 0, lastPinchDist = 0
const onTouchStart = (e) => {
stopAutoRotate()
state.dragging = true
if (e.touches.length === 1) {
lastTX = e.touches[0].clientX
lastTY = e.touches[0].clientY
} else if (e.touches.length === 2) {
lastPinchDist = Math.hypot(
e.touches[0].clientX - e.touches[1].clientX,
e.touches[0].clientY - e.touches[1].clientY,
)
}
}
const onTouchMove = (e) => {
if (!state.dragging) return
if (e.touches.length === 1) {
state.rotY += (e.touches[0].clientX - lastTX) * 0.008
state.rotX += (e.touches[0].clientY - lastTY) * 0.008
state.rotX = Math.max(-0.75, Math.min(0.75, state.rotX))
lastTX = e.touches[0].clientX
lastTY = e.touches[0].clientY
} else if (e.touches.length === 2) {
const dist = Math.hypot(
e.touches[0].clientX - e.touches[1].clientX,
e.touches[0].clientY - e.touches[1].clientY,
)
if (lastPinchDist) state.zoom *= dist / lastPinchDist
state.zoom = Math.max(0.35, Math.min(3.0, state.zoom))
lastPinchDist = dist
}
}
const onTouchEnd = () => { state.dragging = false; lastPinchDist = 0 }
canvas.addEventListener('mousedown', onMouseDown)
window.addEventListener('mousemove', onMouseMove)
window.addEventListener('mouseup', onMouseUp)
canvas.addEventListener('wheel', onWheel, { passive: false })
canvas.addEventListener('touchstart', onTouchStart, { passive: true })
canvas.addEventListener('touchmove', onTouchMove, { passive: true })
canvas.addEventListener('touchend', onTouchEnd)
const onKeyDown = (e) => { if (e.key === 'Escape') onClose() }
window.addEventListener('keydown', onKeyDown)
return () => {
cancelAnimationFrame(state.rafId)
clearTimeout(state.idleTimer)
ro.disconnect()
setReady(false)
canvas.removeEventListener('mousedown', onMouseDown)
window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('mouseup', onMouseUp)
canvas.removeEventListener('wheel', onWheel)
canvas.removeEventListener('touchstart', onTouchStart)
canvas.removeEventListener('touchmove', onTouchMove)
canvas.removeEventListener('touchend', onTouchEnd)
window.removeEventListener('keydown', onKeyDown)
}
}, [onClose])
return (
<div className="wv-overlay" onClick={onClose}>
<div className="wv-modal" onClick={e => e.stopPropagation()}>
<div className="wv-header">
<div className="wv-title-group">
<span className="wv-label">3D Modell</span>
<h3 className="wv-title">Mein Modell</h3>
</div>
<button className="wv-close" onClick={onClose} aria-label="Schließen">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
</svg>
</button>
</div>
<div className="wv-canvas-wrap">
<canvas ref={canvasRef} className={`wv-canvas${ready ? ' wv-canvas--ready' : ''}`} />
{!ready && (
<div className="wv-loader" aria-label="Wird geladen">
<div className="wv-loader-ring" />
<span className="wv-loader-text">Modell wird geladen</span>
</div>
)}
</div>
<div className="wv-footer">
<span className="wv-hint">Ziehen zum Drehen</span>
<span className="wv-hint">Scrollen zum Zoomen</span>
</div>
</div>
</div>
)
}

19
src/main.jsx Normal file
View File

@@ -0,0 +1,19 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import '@fontsource/inter/300.css'
import '@fontsource/inter/400.css'
import '@fontsource/inter/500.css'
import '@fontsource/inter/600.css'
import '@fontsource/inter/700.css'
import '@fontsource/inter/800.css'
import './styles/global.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<App />
</BrowserRouter>
</React.StrictMode>,
)

99
src/pages/Datenschutz.jsx Normal file
View File

@@ -0,0 +1,99 @@
import { useEffect } from 'react'
import { Link } from 'react-router-dom'
import './LegalPage.css'
export default function Datenschutz() {
useEffect(() => {
const host = window.location.hostname
if (host === 'localhost' || host === '127.0.0.1') return
const script = document.createElement('script')
script.id = 'CookieDeclaration'
script.src = 'https://consent.cookiebot.com/e49bbfc0-7402-4cda-9ae4-5bd1201ea9b5/cd.js'
script.type = 'text/javascript'
script.async = true
const anchor = document.getElementById('cookie-declaration-anchor')
if (anchor) anchor.appendChild(script)
return () => { script.remove() }
}, [])
return (
<div className="legal-page section">
<div className="container--narrow">
<Link to="/" className="legal-back">
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 4L6 8l4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
Zurück
</Link>
<h1 className="legal-title">Datenschutzerklärung</h1>
<div className="legal-content">
<h2>1. Datenschutz auf einen Blick</h2>
<h3>Allgemeine Hinweise</h3>
<p>
Die folgenden Hinweise geben einen einfachen Überblick darüber, was mit Ihren
personenbezogenen Daten passiert, wenn Sie diese Website besuchen.
</p>
<h2>2. Allgemeine Hinweise und Pflichtinformationen</h2>
<h3>Datenschutz</h3>
<p>
Die Betreiber dieser Seiten nehmen den Schutz Ihrer persönlichen Daten sehr ernst.
Wir behandeln Ihre personenbezogenen Daten vertraulich und entsprechend der
gesetzlichen Datenschutzvorschriften sowie dieser Datenschutzerklärung.
</p>
<h3>Verantwortliche Stelle</h3>
<p>
Superfice KG<br />
Musterstraße 1<br />
10115 Berlin<br />
E-Mail: info@superfice.de
</p>
<h2>3. Datenerfassung auf dieser Website</h2>
<h3>Kontaktformular</h3>
<p>
Wenn Sie uns per Kontaktformular Anfragen zukommen lassen, werden Ihre Angaben aus
dem Anfrageformular inklusive der von Ihnen dort angegebenen Kontaktdaten zwecks
Bearbeitung der Anfrage und für den Fall von Anschlussfragen bei uns gespeichert.
Diese Daten geben wir nicht ohne Ihre Einwilligung weiter.
</p>
<p>
Die Verarbeitung dieser Daten erfolgt auf Grundlage von Art. 6 Abs. 1 lit. b DSGVO,
sofern Ihre Anfrage mit der Erfüllung eines Vertrags zusammenhängt oder zur
Durchführung vorvertraglicher Maßnahmen erforderlich ist.
</p>
<h3>Server-Log-Dateien</h3>
<p>
Der Provider der Seiten erhebt und speichert automatisch Informationen in sogenannten
Server-Log-Dateien, die Ihr Browser automatisch an uns übermittelt. Dies sind:
Browsertyp und Browserversion, verwendetes Betriebssystem, Referrer URL,
Hostname des zugreifenden Rechners, Uhrzeit der Serveranfrage, IP-Adresse.
</p>
<h2>4. Ihre Rechte</h2>
<p>
Sie haben jederzeit das Recht auf unentgeltliche Auskunft über Ihre gespeicherten
personenbezogenen Daten, deren Herkunft und Empfänger und den Zweck der
Datenverarbeitung sowie ein Recht auf Berichtigung oder Löschung dieser Daten.
Hierzu sowie zu weiteren Fragen zum Thema personenbezogene Daten können Sie sich
jederzeit unter der im Impressum angegebenen Adresse an uns wenden.
</p>
<h2>5. Cookies</h2>
<p>
Diese Website verwendet Cookies. Nachfolgend finden Sie eine vollständige Übersicht
aller eingesetzten Cookies sowie die Möglichkeit, Ihre Einwilligung jederzeit zu
widerrufen oder anzupassen.
</p>
<div id="cookie-declaration-anchor" />
<p className="legal-note">Stand: 2026</p>
</div>
</div>
</div>
)
}

88
src/pages/Impressum.jsx Normal file
View File

@@ -0,0 +1,88 @@
import { Link } from 'react-router-dom'
import './LegalPage.css'
export default function Impressum() {
return (
<div className="legal-page section">
<div className="container--narrow">
<Link to="/" className="legal-back">
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 4L6 8l4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
Zurück
</Link>
<h1 className="legal-title">Impressum</h1>
<div className="legal-content">
<h2>Angaben gemäß § 5 TMG</h2>
<p>
Superfice.de KG<br />
Grüner Weg 36<br />
03185 Peitz
</p>
<h2>Kontakt</h2>
<p>
Telefon: +49 35601 988891<br />
E-Mail: <a href="mailto:info@superfice.de">info@superfice.de</a><br />
Web: <a href="https://www.superfice.de" target="_blank" rel="noopener noreferrer">www.superfice.de</a>
</p>
<h2>Registereintrag</h2>
<p>
Registergericht: Amtsgericht Cottbus<br />
Registernummer: HRA 4427 CB
</p>
<h2>Umsatzsteuer-ID</h2>
<p>
Umsatzsteuer-Identifikationsnummer gemäß § 27 a Umsatzsteuergesetz:<br />
DE316486342
</p>
<h2>Steuernummer</h2>
<p>056/163/01936</p>
<h2>Geschäftsführung</h2>
<p>Marco Vitalone</p>
<h2>Bankverbindung</h2>
<p>
Superfice.de KG<br />
Deutsche Bank<br />
IBAN: DE72 8707 0024 0534 5145 00<br />
BIC: DEUTDEDBCHE
</p>
<h2>Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV</h2>
<p>
Marco Vitalone<br />
Superfice.de KG<br />
Grüner Weg 36<br />
03185 Peitz
</p>
<h2>Haftungsausschluss</h2>
<h3>Haftung für Inhalte</h3>
<p>
Die Inhalte unserer Seiten wurden mit größter Sorgfalt erstellt. Für die Richtigkeit,
Vollständigkeit und Aktualität der Inhalte können wir jedoch keine Gewähr übernehmen.
</p>
<h3>Haftung für Links</h3>
<p>
Unser Angebot enthält Links zu externen Webseiten Dritter. Auf die Inhalte dieser
externen Seiten haben wir keinen Einfluss und können daher für diese fremden Inhalte
keine Gewähr übernehmen.
</p>
<h2>Urheberrecht</h2>
<p>
Die durch die Seitenbetreiber erstellten Inhalte und Werke auf diesen Seiten unterliegen
dem deutschen Urheberrecht. © 2026 Superfice.de KG
</p>
</div>
</div>
</div>
)
}

67
src/pages/LegalPage.css Normal file
View File

@@ -0,0 +1,67 @@
.legal-page {
background: var(--color-bg);
min-height: 80vh;
padding-top: calc(68px + var(--section-padding-y));
}
.legal-back {
display: inline-flex;
align-items: center;
gap: var(--space-2);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
margin-bottom: var(--space-10);
transition: color var(--transition);
}
.legal-back:hover {
color: var(--color-text);
}
.legal-back svg {
width: 16px;
height: 16px;
}
.legal-title {
font-size: clamp(2rem, 4vw, 3rem);
font-weight: 800;
letter-spacing: -0.03em;
color: var(--color-text);
margin-bottom: var(--space-10);
}
.legal-content {
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.legal-content h2 {
font-size: var(--font-size-lg);
font-weight: 700;
color: var(--color-text);
margin-top: var(--space-6);
margin-bottom: calc(var(--space-6) * -1 + var(--space-2));
}
.legal-content h3 {
font-size: var(--font-size-base);
font-weight: 600;
color: var(--color-text);
margin-top: var(--space-2);
margin-bottom: calc(var(--space-6) * -1 + var(--space-2));
}
.legal-content p {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
line-height: 1.75;
}
.legal-note {
font-size: var(--font-size-xs) !important;
color: var(--color-text-faint) !important;
padding-top: var(--space-8);
border-top: 1px solid var(--color-border);
}

139
src/styles/global.css Normal file
View File

@@ -0,0 +1,139 @@
@import './variables.css';
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-size: 16px;
}
body {
font-family: var(--font);
background-color: var(--color-bg);
color: var(--color-text);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overflow-x: hidden;
}
a {
color: inherit;
text-decoration: none;
}
button {
font-family: var(--font);
cursor: pointer;
border: none;
background: none;
}
input, textarea, select {
font-family: var(--font);
}
img {
max-width: 100%;
display: block;
}
/* Utility classes */
.container {
max-width: var(--max-width);
margin: 0 auto;
padding: 0 var(--section-padding-x);
}
.container--narrow {
max-width: var(--max-width-narrow);
margin: 0 auto;
padding: 0 var(--section-padding-x);
}
.section {
padding: var(--section-padding-y) 0;
}
/* Button styles */
.btn {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: 0.875rem 2rem;
border-radius: var(--radius);
font-size: var(--font-size-sm);
font-weight: 600;
letter-spacing: 0.03em;
text-transform: uppercase;
transition: all var(--transition);
cursor: pointer;
border: none;
white-space: nowrap;
}
.btn--primary {
background-color: var(--color-accent);
color: var(--color-white);
}
.btn--primary:hover {
background-color: var(--color-accent-hover);
transform: translateY(-1px);
box-shadow: var(--shadow-accent);
}
.btn--outline {
background: transparent;
color: var(--color-text);
border: 1px solid var(--color-accent-border);
}
.btn--outline:hover {
border-color: var(--color-accent);
color: var(--color-white);
background: var(--color-accent-dim);
}
.btn--ghost {
background: transparent;
color: var(--color-text-muted);
padding: 0.875rem 1.5rem;
}
.btn--ghost:hover {
color: var(--color-text);
}
/* Scrollbar */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: var(--color-bg);
}
::-webkit-scrollbar-thumb {
background: var(--color-surface-3);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-accent);
}
/* Focus */
:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
/* Selection */
::selection {
background: var(--color-accent);
color: var(--color-white);
}

76
src/styles/variables.css Normal file
View File

@@ -0,0 +1,76 @@
:root {
/* Colors */
--color-bg: #0D0D0D;
--color-surface: #181818;
--color-surface-2: #222222;
--color-surface-3: #2C2C2C;
--color-accent: #8B3DB8;
--color-accent-hover: #A04ED0;
--color-accent-dim: rgba(139, 61, 184, 0.18);
--color-accent-border: rgba(139, 61, 184, 0.55);
--color-text: #F5F5F5;
--color-text-muted: #B0B0B0;
--color-text-faint: #707070;
--color-border: rgba(255, 255, 255, 0.11);
--color-border-hover: rgba(255, 255, 255, 0.22);
--color-white: #FFFFFF;
/* Typography */
--font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-size-xs: 0.75rem;
--font-size-sm: 0.875rem;
--font-size-base: 1rem;
--font-size-md: 1.125rem;
--font-size-lg: 1.25rem;
--font-size-xl: 1.5rem;
--font-size-2xl: 2rem;
--font-size-3xl: 2.75rem;
--font-size-4xl: 3.75rem;
--font-size-5xl: 5rem;
/* Spacing */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-5: 1.25rem;
--space-6: 1.5rem;
--space-8: 2rem;
--space-10: 2.5rem;
--space-12: 3rem;
--space-16: 4rem;
--space-20: 5rem;
--space-24: 6rem;
--space-32: 8rem;
/* Layout */
--max-width: 1200px;
--max-width-narrow: 760px;
--section-padding-y: 6rem;
--section-padding-x: 1.5rem;
/* Borders */
--radius-sm: 4px;
--radius: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
/* Transitions */
--transition: 0.2s ease;
--transition-slow: 0.4s ease;
/* Shadows */
--shadow: 0 2px 16px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 8px 40px rgba(0, 0, 0, 0.6);
--shadow-accent: 0 0 40px rgba(139, 61, 184, 0.35);
}
@media (max-width: 768px) {
:root {
--section-padding-y: 4rem;
--section-padding-x: 1.25rem;
--font-size-3xl: 2rem;
--font-size-4xl: 2.5rem;
--font-size-5xl: 3rem;
}
}

21
src/utils/api.js Normal file
View File

@@ -0,0 +1,21 @@
const API_BASE = import.meta.env.VITE_API_BASE ?? ''
export async function fetchDistance(plz) {
const res = await fetch(`${API_BASE}/api/distance.php?plz=${encodeURIComponent(plz)}`)
if (!res.ok) throw new Error('Netzwerkfehler')
const data = await res.json()
if (data.error) throw new Error(data.error)
return data.distance
}
export async function submitContact(formData) {
const res = await fetch(`${API_BASE}/api/contact.php`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
})
if (!res.ok) throw new Error('Netzwerkfehler')
const data = await res.json()
if (!data.success) throw new Error(data.error ?? 'Unbekannter Fehler')
return data
}

44
src/utils/pricing.js Normal file
View File

@@ -0,0 +1,44 @@
export const BASE_PRICES = {
kuche: 195,
raum: 130,
treppe: 165,
schrank: 110,
bad: 155,
fenster: 18,
wintergarten: 295,
individuell: 0,
}
export const QUANTITY_CONFIG = {
kuche: { label: 'Wie viele Küchen?', unit: 'Küche', min: 1, max: 5, step: 1 },
raum: { label: 'Wie viele Räume / Etagen?', unit: 'Raum', min: 1, max: 20, step: 1 },
treppe: { label: 'Wie viele Treppen?', unit: 'Treppe', min: 1, max: 5, step: 1 },
schrank: { label: 'Wie viele Einheiten?', unit: 'Einheit', min: 1, max: 10, step: 1 },
bad: { label: 'Wie viele Bäder?', unit: 'Bad', min: 1, max: 5, step: 1 },
fenster: { label: 'Wie viele Fenster / Öffnungen?', unit: 'Fenster', min: 5, max: 100, step: 5 },
wintergarten: { label: 'Wie viele Wintergärten?', unit: 'Wintergarten', min: 1, max: 3, step: 1 },
individuell: { label: null, unit: null, min: 1, max: 1, step: 1 },
}
export function calculateTravelCost(km) {
if (!km || km <= 30) return 0
return Math.round((km - 30) * 0.65)
}
export function calculateSubtotal(services, quantities) {
return services.reduce((total, id) => {
if (id === 'individuell') return total
const qty = quantities[id] ?? QUANTITY_CONFIG[id]?.min ?? 1
return total + (BASE_PRICES[id] ?? 0) * qty
}, 0)
}
export function calculateTotal(services, quantities, distanceKm) {
const subtotal = calculateSubtotal(services, quantities)
const travel = calculateTravelCost(distanceKm)
return subtotal + travel
}
export function hasIndividuell(services) {
return services.includes('individuell')
}

27
vite.config.js Normal file
View File

@@ -0,0 +1,27 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
sourcemap: false,
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom', 'react-router-dom'],
},
},
},
chunkSizeWarningLimit: 600,
},
})