Initial commit — Superfice.de website
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* text=auto
|
||||
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
.DS_Store
|
||||
*.log
|
||||
api/config.php
|
||||
.claude/
|
||||
memory/
|
||||
Thumbs.db
|
||||
22
api/.htaccess
Normal file
22
api/.htaccess
Normal 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
133
api/contact.php
Normal 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
77
api/distance.php
Normal 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
112
api/helpers.php
Normal 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
19
index.html
Normal 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
1729
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
package.json
Normal file
21
package.json
Normal 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
5
public/favicon.svg
Normal 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
5
src/App.css
Normal file
@@ -0,0 +1,5 @@
|
||||
main {
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
55
src/App.jsx
Normal file
55
src/App.jsx
Normal 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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
162
src/components/BathViewer/BathViewer.css
Normal file
162
src/components/BathViewer/BathViewer.css
Normal 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;
|
||||
}
|
||||
559
src/components/BathViewer/BathViewer.jsx
Normal file
559
src/components/BathViewer/BathViewer.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
162
src/components/BedroomViewer/BedroomViewer.css
Normal file
162
src/components/BedroomViewer/BedroomViewer.css
Normal 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;
|
||||
}
|
||||
480
src/components/BedroomViewer/BedroomViewer.jsx
Normal file
480
src/components/BedroomViewer/BedroomViewer.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
162
src/components/ClosetViewer/ClosetViewer.css
Normal file
162
src/components/ClosetViewer/ClosetViewer.css
Normal 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;
|
||||
}
|
||||
317
src/components/ClosetViewer/ClosetViewer.jsx
Normal file
317
src/components/ClosetViewer/ClosetViewer.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
192
src/components/Contact/Contact.css
Normal file
192
src/components/Contact/Contact.css
Normal 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;
|
||||
}
|
||||
}
|
||||
112
src/components/Contact/Contact.jsx
Normal file
112
src/components/Contact/Contact.jsx
Normal 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 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">
|
||||
Mo–Fr, 8–18 Uhr · Antwort garantiert innerhalb von 24 h
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
472
src/components/CostCalculator/CostCalculator.css
Normal file
472
src/components/CostCalculator/CostCalculator.css
Normal 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;
|
||||
}
|
||||
}
|
||||
381
src/components/CostCalculator/CostCalculator.jsx
Normal file
381
src/components/CostCalculator/CostCalculator.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
27
src/components/CustomCursor/CustomCursor.css
Normal file
27
src/components/CustomCursor/CustomCursor.css
Normal 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;
|
||||
}
|
||||
}
|
||||
231
src/components/CustomCursor/CustomCursor.jsx
Normal file
231
src/components/CustomCursor/CustomCursor.jsx
Normal 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
|
||||
}
|
||||
127
src/components/Footer/Footer.css
Normal file
127
src/components/Footer/Footer.css
Normal 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;
|
||||
}
|
||||
}
|
||||
68
src/components/Footer/Footer.jsx
Normal file
68
src/components/Footer/Footer.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
204
src/components/Hero/Hero.css
Normal file
204
src/components/Hero/Hero.css
Normal 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;
|
||||
}
|
||||
}
|
||||
77
src/components/Hero/Hero.jsx
Normal file
77
src/components/Hero/Hero.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
162
src/components/KitchenViewer/KitchenViewer.css
Normal file
162
src/components/KitchenViewer/KitchenViewer.css
Normal 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;
|
||||
}
|
||||
578
src/components/KitchenViewer/KitchenViewer.jsx
Normal file
578
src/components/KitchenViewer/KitchenViewer.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
257
src/components/Navigation/Navigation.css
Normal file
257
src/components/Navigation/Navigation.css
Normal 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;
|
||||
}
|
||||
}
|
||||
90
src/components/Navigation/Navigation.jsx
Normal file
90
src/components/Navigation/Navigation.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
30
src/components/PainPoint/PainPoint.css
Normal file
30
src/components/PainPoint/PainPoint.css
Normal 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;
|
||||
}
|
||||
15
src/components/PainPoint/PainPoint.jsx
Normal file
15
src/components/PainPoint/PainPoint.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
77
src/components/References/References.css
Normal file
77
src/components/References/References.css
Normal 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);
|
||||
}
|
||||
40
src/components/References/References.jsx
Normal file
40
src/components/References/References.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
162
src/components/RoomViewer/RoomViewer.css
Normal file
162
src/components/RoomViewer/RoomViewer.css
Normal 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;
|
||||
}
|
||||
628
src/components/RoomViewer/RoomViewer.jsx
Normal file
628
src/components/RoomViewer/RoomViewer.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
80
src/components/Services/Services.css
Normal file
80
src/components/Services/Services.css
Normal 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;
|
||||
}
|
||||
}
|
||||
42
src/components/Services/Services.jsx
Normal file
42
src/components/Services/Services.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
10
src/components/SpaceBackground/SpaceBackground.css
Normal file
10
src/components/SpaceBackground/SpaceBackground.css
Normal 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;
|
||||
}
|
||||
415
src/components/SpaceBackground/SpaceBackground.jsx
Normal file
415
src/components/SpaceBackground/SpaceBackground.jsx
Normal 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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
200
src/components/StairViewer/StairViewer.css
Normal file
200
src/components/StairViewer/StairViewer.css
Normal 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;
|
||||
}
|
||||
}
|
||||
422
src/components/StairViewer/StairViewer.jsx
Normal file
422
src/components/StairViewer/StairViewer.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
22
src/components/StaircaseScroller/StaircaseScroller.css
Normal file
22
src/components/StaircaseScroller/StaircaseScroller.css
Normal 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;
|
||||
}
|
||||
}
|
||||
244
src/components/StaircaseScroller/StaircaseScroller.jsx
Normal file
244
src/components/StaircaseScroller/StaircaseScroller.jsx
Normal 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.55–0.85 at front
|
||||
: 0.14 + (avgZ + 1) * 0.06 // 0.14–0.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>
|
||||
)
|
||||
}
|
||||
162
src/components/TerraceViewer/TerraceViewer.css
Normal file
162
src/components/TerraceViewer/TerraceViewer.css
Normal 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;
|
||||
}
|
||||
382
src/components/TerraceViewer/TerraceViewer.jsx
Normal file
382
src/components/TerraceViewer/TerraceViewer.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
152
src/components/WhatWeMeasure/WhatWeMeasure.css
Normal file
152
src/components/WhatWeMeasure/WhatWeMeasure.css
Normal 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;
|
||||
}
|
||||
}
|
||||
204
src/components/WhatWeMeasure/WhatWeMeasure.jsx
Normal file
204
src/components/WhatWeMeasure/WhatWeMeasure.jsx
Normal 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 }
|
||||
109
src/components/WhySuperfice/WhySuperfice.css
Normal file
109
src/components/WhySuperfice/WhySuperfice.css
Normal 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;
|
||||
}
|
||||
}
|
||||
93
src/components/WhySuperfice/WhySuperfice.jsx
Normal file
93
src/components/WhySuperfice/WhySuperfice.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
162
src/components/WireframeViewer/WireframeViewer.css
Normal file
162
src/components/WireframeViewer/WireframeViewer.css
Normal 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;
|
||||
}
|
||||
275
src/components/WireframeViewer/WireframeViewer.jsx
Normal file
275
src/components/WireframeViewer/WireframeViewer.jsx
Normal 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
19
src/main.jsx
Normal 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
99
src/pages/Datenschutz.jsx
Normal 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
88
src/pages/Impressum.jsx
Normal 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
67
src/pages/LegalPage.css
Normal 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
139
src/styles/global.css
Normal 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
76
src/styles/variables.css
Normal 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
21
src/utils/api.js
Normal 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
44
src/utils/pricing.js
Normal 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
27
vite.config.js
Normal 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,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user