update solution page
This commit is contained in:
@@ -14856,7 +14856,7 @@ img:is([sizes=auto i]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.elementor-element.elementor-element-7da6646:not(.elementor-motion-effects-element-type-background) {
|
.elementor-element.elementor-element-7da6646:not(.elementor-motion-effects-element-type-background) {
|
||||||
background-image: url("/images/bg-header-5.webp");
|
background-image: url("/images/ev.webp");
|
||||||
background-position: center center;
|
background-position: center center;
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
background-size: cover;
|
background-size: cover;
|
||||||
|
|||||||
BIN
public/images/bg-header-5.png
Normal file
BIN
public/images/bg-header-5.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.3 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 0 B |
BIN
public/images/ev fleet1.png
Normal file
BIN
public/images/ev fleet1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
@@ -27,7 +27,7 @@ export default function Home() {
|
|||||||
<MarqueeTicker />
|
<MarqueeTicker />
|
||||||
<ConnectedLogistics />
|
<ConnectedLogistics />
|
||||||
<TheDoormileWay />
|
<TheDoormileWay />
|
||||||
<EVSection />
|
<EVSection bannerImage="/images/bg-header-5.png" image="/images/premium-ev-van.webp" />
|
||||||
<IndustrySolutions />
|
<IndustrySolutions />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import SolutionsHero, { type HeroSlide } from "@/components/sections/SolutionsHero";
|
import SolutionsHero, { type HeroSlide } from "@/components/sections/SolutionsHero";
|
||||||
import Miles3 from "@/components/sections/Miles3";
|
import Miles3 from "@/components/sections/Miles3";
|
||||||
|
import PlatformCapabilities from "@/components/sections/PlatformCapabilities";
|
||||||
|
import DeliveredCounter from "@/components/sections/DeliveredCounter";
|
||||||
import EVSection, { type EVFeature } from "@/components/sections/EVSection";
|
import EVSection, { type EVFeature } from "@/components/sections/EVSection";
|
||||||
import {
|
import {
|
||||||
SolutionsSectionsShell,
|
SolutionsSectionsShell,
|
||||||
@@ -96,7 +98,17 @@ export default function SolutionsPage() {
|
|||||||
so their Elementor markup is unaffected. */}
|
so their Elementor markup is unaffected. */}
|
||||||
<SolutionsSectionsShell>
|
<SolutionsSectionsShell>
|
||||||
<SolOfferings />
|
<SolOfferings />
|
||||||
<Miles3 eyebrow="/ What We Offer /" />
|
{/* Platform Capabilities carousel is passed as children so it
|
||||||
|
renders INSIDE the Miles3 dark card, directly under the
|
||||||
|
first/mid/last mile grid — one continuous "What We Offer"
|
||||||
|
section, not a second one. Scoped to #pcap-root, so it does
|
||||||
|
not inherit anything from the shell's `.sol-*` stylesheet. */}
|
||||||
|
<Miles3 eyebrow="/ What We Offer /">
|
||||||
|
{/* Big count-up stat over the dotted world map, then the
|
||||||
|
capabilities carousel — both inside the same dark card. */}
|
||||||
|
<DeliveredCounter />
|
||||||
|
<PlatformCapabilities />
|
||||||
|
</Miles3>
|
||||||
<SolWingsSpotlight />
|
<SolWingsSpotlight />
|
||||||
<EVSection
|
<EVSection
|
||||||
eyebrow="/ EV Fleet /"
|
eyebrow="/ EV Fleet /"
|
||||||
@@ -106,6 +118,8 @@ export default function SolutionsPage() {
|
|||||||
titleAccent="YOUR ROUTES."
|
titleAccent="YOUR ROUTES."
|
||||||
features={EV_FLEET_FEATURES}
|
features={EV_FLEET_FEATURES}
|
||||||
ariaLabel="Doormile EV fleet"
|
ariaLabel="Doormile EV fleet"
|
||||||
|
bannerImage="/images/ev fleet1.png"
|
||||||
|
image="/images/ev.webp"
|
||||||
/>
|
/>
|
||||||
<SolutionsWhyDoormile />
|
<SolutionsWhyDoormile />
|
||||||
<SolMileTruthBand />
|
<SolMileTruthBand />
|
||||||
|
|||||||
368
src/components/sections/AiCoreAnimation.tsx
Normal file
368
src/components/sections/AiCoreAnimation.tsx
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
|
||||||
|
interface AiCoreAnimationProps {
|
||||||
|
variant?: "compact" | "full";
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AiCoreAnimation({
|
||||||
|
variant = "full",
|
||||||
|
className = "",
|
||||||
|
}: AiCoreAnimationProps) {
|
||||||
|
const isCompact = variant === "compact";
|
||||||
|
|
||||||
|
// Cycle phase for organic ecosystem simulation (0 to 10)
|
||||||
|
const [phase, setPhase] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setPhase((prev) => (prev + 1) % 11);
|
||||||
|
}, 1250); // ~13.7s total continuous ecosystem cycle
|
||||||
|
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (isCompact) {
|
||||||
|
return (
|
||||||
|
<div className={`sol-s05-svg-core ${className}`} style={{ width: "100%", height: "100%", position: "relative" }}>
|
||||||
|
<style jsx>{`
|
||||||
|
.compact-route-dash {
|
||||||
|
stroke-dasharray: 4 6;
|
||||||
|
animation: cDashFlow 2s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes cDashFlow { to { stroke-dashoffset: -20; } }
|
||||||
|
`}</style>
|
||||||
|
<svg viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ width: "100%", height: "100%" }}>
|
||||||
|
<path d="M 10 30 H 90 M 10 70 H 90 M 30 10 V 90 M 70 10 V 90" stroke="rgba(14,14,16,0.1)" strokeWidth="1.5" />
|
||||||
|
<path d="M 30 70 C 45 30, 55 30, 70 30" stroke="#4ADE80" strokeWidth="2.5" strokeLinecap="round" className="compact-route-dash" />
|
||||||
|
<circle cx="30" cy="70" r="4" fill="#0C0C14" />
|
||||||
|
<circle cx="70" cy="30" r="4" fill="#4ADE80" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ecosystem phase indicators
|
||||||
|
const isTrafficActive = phase >= 2 && phase <= 4;
|
||||||
|
const isNetworkReacting = phase >= 3 && phase <= 8;
|
||||||
|
const isBypassIlluminated = phase >= 4 && phase <= 8;
|
||||||
|
const isEtaImproved = phase >= 6;
|
||||||
|
const isDelivered = phase >= 8;
|
||||||
|
const isSlaProtected = phase >= 9;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`mt-living-ecosystem ${className}`}
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
background: "transparent",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<style jsx>{`
|
||||||
|
.ecosystem-hero-svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
max-width: 680px;
|
||||||
|
max-height: 440px;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Flowing Laser Signal Dashes across City Network */
|
||||||
|
.city-laser-flow {
|
||||||
|
stroke-dasharray: 10 16;
|
||||||
|
animation: cityLaserDash 2.4s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.city-laser-fast {
|
||||||
|
stroke-dasharray: 14 10;
|
||||||
|
animation: cityLaserDash 1.5s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cityLaserDash {
|
||||||
|
to { stroke-dashoffset: -52; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Traffic Congestion Segment Glow */
|
||||||
|
.traffic-red-pulse {
|
||||||
|
animation: redPulse 1.2s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes redPulse {
|
||||||
|
0% { stroke-opacity: 0.45; }
|
||||||
|
100% { stroke-opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smooth Easing Vehicle Motion */
|
||||||
|
.vehicle-pod-smooth {
|
||||||
|
transition: all 1.15s cubic-bezier(0.25, 1, 0.5, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Floating Apple-Style Badges */
|
||||||
|
.apple-pill-float {
|
||||||
|
animation: appleFloat 3s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes appleFloat {
|
||||||
|
0% { transform: translateY(0px); }
|
||||||
|
100% { transform: translateY(-4px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ambient Particle Drift */
|
||||||
|
.p-drift-1 { animation: pDrift1 14s linear infinite; }
|
||||||
|
.p-drift-2 { animation: pDrift2 18s linear infinite; }
|
||||||
|
|
||||||
|
@keyframes pDrift1 {
|
||||||
|
0% { transform: translate(0, 0); }
|
||||||
|
50% { transform: translate(15px, -12px); }
|
||||||
|
100% { transform: translate(0, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pDrift2 {
|
||||||
|
0% { transform: translate(0, 0); }
|
||||||
|
50% { transform: translate(-20px, 15px); }
|
||||||
|
100% { transform: translate(0, 0); }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 680 440"
|
||||||
|
className="ecosystem-hero-svg"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
{/* Micro Dot Grid Pattern for Clean Off-White Background */}
|
||||||
|
<pattern id="appleHeroDotGrid" width="24" height="24" patternUnits="userSpaceOnUse">
|
||||||
|
<circle cx="12" cy="12" r="1" fill="rgba(14, 14, 16, 0.05)" />
|
||||||
|
</pattern>
|
||||||
|
|
||||||
|
{/* Emerald Green Route Gradient */}
|
||||||
|
<linearGradient id="emeraldCityGrad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stopColor="#22C55E" />
|
||||||
|
<stop offset="50%" stopColor="#4ADE80" />
|
||||||
|
<stop offset="100%" stopColor="#22C55E" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
{/* Red Congestion Route Gradient */}
|
||||||
|
<linearGradient id="redTrafficGrad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stopColor="#EF4444" />
|
||||||
|
<stop offset="100%" stopColor="#DC2626" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
{/* Apple Product Artwork Soft Shadow */}
|
||||||
|
<filter id="appleHeroShadow" x="-30%" y="-30%" width="160%" height="160%">
|
||||||
|
<feDropShadow dx="0" dy="4" stdDeviation="6" floodColor="#000000" floodOpacity="0.07" />
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{/* ============================================================
|
||||||
|
1. OFF-WHITE CANVAS & AMBIENT TELEMETRY PARTICLES
|
||||||
|
============================================================ */}
|
||||||
|
<rect width="680" height="440" fill="url(#appleHeroDotGrid)" opacity="0.9" />
|
||||||
|
|
||||||
|
{/* Ambient Telemetry Particles Drifting Across Network */}
|
||||||
|
<g className="p-drift-1">
|
||||||
|
<circle cx="140" cy="80" r="1.5" fill="rgba(14,14,16,0.25)" />
|
||||||
|
<circle cx="520" cy="380" r="2" fill="rgba(74,222,128,0.3)" />
|
||||||
|
<circle cx="340" cy="60" r="1.5" fill="rgba(14,14,16,0.2)" />
|
||||||
|
</g>
|
||||||
|
<g className="p-drift-2">
|
||||||
|
<circle cx="480" cy="360" r="1.5" fill="rgba(14,14,16,0.25)" />
|
||||||
|
<circle cx="200" cy="380" r="2" fill="rgba(239,68,68,0.3)" />
|
||||||
|
<circle cx="580" cy="80" r="1.5" fill="rgba(14,14,16,0.2)" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* ============================================================
|
||||||
|
2. ABSTRACT CITY LOGISTICS NETWORK TOPOLOGY
|
||||||
|
============================================================ */}
|
||||||
|
|
||||||
|
{/* THIN GREY ARTERIAL HIGHWAYS & STREETS */}
|
||||||
|
<path d="M 80 100 H 600" stroke="rgba(14, 14, 16, 0.1)" strokeWidth="2" />
|
||||||
|
<path d="M 80 220 H 600" stroke="rgba(14, 14, 16, 0.12)" strokeWidth="2.5" />
|
||||||
|
<path d="M 80 340 H 600" stroke="rgba(14, 14, 16, 0.1)" strokeWidth="2" />
|
||||||
|
|
||||||
|
{/* CONNECTING VERTICAL AVENUES */}
|
||||||
|
<path d="M 200 60 V 380" stroke="rgba(14, 14, 16, 0.08)" strokeWidth="1.8" />
|
||||||
|
<path d="M 340 60 V 380" stroke="rgba(14, 14, 16, 0.08)" strokeWidth="1.8" />
|
||||||
|
<path d="M 480 60 V 380" stroke="rgba(14, 14, 16, 0.08)" strokeWidth="1.8" />
|
||||||
|
|
||||||
|
{/* DIAGONAL BYPASS CONNECTORS & RING EXPRESSWAY ARCS */}
|
||||||
|
<path d="M 200 220 L 340 100 L 480 220" stroke="rgba(14, 14, 16, 0.09)" strokeWidth="2" fill="none" />
|
||||||
|
<path d="M 200 220 L 340 340 L 480 220" stroke="rgba(14, 14, 16, 0.09)" strokeWidth="2" fill="none" />
|
||||||
|
<path d="M 80 220 C 180 60, 500 60, 600 220" stroke="rgba(14, 14, 16, 0.06)" strokeWidth="1.5" strokeDasharray="4 6" fill="none" />
|
||||||
|
|
||||||
|
{/* LIVE DATA SIGNALS FLOWING ACROSS CITY NETWORK */}
|
||||||
|
<path d="M 80 100 H 600" stroke="rgba(14, 14, 16, 0.2)" strokeWidth="1.2" fill="none" className="city-laser-flow" />
|
||||||
|
<path d="M 80 340 H 600" stroke="rgba(14, 14, 16, 0.2)" strokeWidth="1.2" fill="none" className="city-laser-flow" />
|
||||||
|
<path d="M 340 60 V 380" stroke="rgba(14, 14, 16, 0.15)" strokeWidth="1.2" fill="none" className="city-laser-flow" />
|
||||||
|
|
||||||
|
{/* ============================================================
|
||||||
|
3. DYNAMIC ROAD STATES (Congestion Red vs Emerald Bypass)
|
||||||
|
============================================================ */}
|
||||||
|
|
||||||
|
{/* CONGESTED ROAD SEGMENT (200, 220 -> 480, 220) */}
|
||||||
|
<path
|
||||||
|
d="M 200 220 H 480"
|
||||||
|
stroke={isTrafficActive ? "url(#redTrafficGrad)" : "rgba(14, 14, 16, 0.12)"}
|
||||||
|
strokeWidth={isTrafficActive ? "5" : "2.5"}
|
||||||
|
strokeDasharray={isBypassIlluminated ? "4 8" : "none"}
|
||||||
|
className={isTrafficActive ? "traffic-red-pulse" : ""}
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ORGANIC TRAFFIC ALERT INDICATOR AT CONGESTION ZONE */}
|
||||||
|
{isTrafficActive && (
|
||||||
|
<g transform="translate(340, 220)">
|
||||||
|
<circle cx="0" cy="0" r="16" fill="rgba(239, 68, 68, 0.2)" />
|
||||||
|
<circle cx="0" cy="0" r="7" fill="#EF4444" filter="url(#appleHeroShadow)" />
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* SUBTLE ILLUMINATED EMERALD GREEN BYPASS ROAD (200, 220 -> 340, 100 -> 480, 220) */}
|
||||||
|
{isBypassIlluminated && (
|
||||||
|
<g>
|
||||||
|
<path
|
||||||
|
d="M 200 220 L 340 100 L 480 220"
|
||||||
|
stroke="url(#emeraldCityGrad)"
|
||||||
|
strokeWidth="4.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
fill="none"
|
||||||
|
className="city-laser-fast"
|
||||||
|
filter="url(#appleHeroShadow)"
|
||||||
|
/>
|
||||||
|
{/* Ambient Green Glow Trail */}
|
||||||
|
<path
|
||||||
|
d="M 200 220 L 340 100 L 480 220"
|
||||||
|
stroke="#4ADE80"
|
||||||
|
strokeWidth="10"
|
||||||
|
strokeOpacity="0.22"
|
||||||
|
strokeLinecap="round"
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ============================================================
|
||||||
|
4. MINIMAL LOGISTICS FACILITIES (Warehouses, Hubs, Destinations)
|
||||||
|
============================================================ */}
|
||||||
|
|
||||||
|
{/* Warehouse 1 (North Dispatch at 80, 100) */}
|
||||||
|
<g transform="translate(80, 100)">
|
||||||
|
<rect x="-14" y="-12" width="28" height="24" rx="5" fill="#FFFFFF" stroke="#0C0C14" strokeWidth="1.5" filter="url(#appleHeroShadow)" />
|
||||||
|
<path d="M-5 4V-2l5-3 5 3v5" stroke="#0C0C14" strokeWidth="1.2" fill="none" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* Warehouse 2 (Main Dispatch at 80, 220) */}
|
||||||
|
<g transform="translate(80, 220)">
|
||||||
|
<rect x="-16" y="-14" width="32" height="28" rx="6" fill="#0C0C14" stroke="#FFFFFF" strokeWidth="1.5" filter="url(#appleHeroShadow)" />
|
||||||
|
<path d="M-6 5V-2l6-4 6 4v7" stroke="#FFFFFF" strokeWidth="1.5" fill="none" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* Distribution Hub 1 (200, 220) */}
|
||||||
|
<circle cx="200" cy="220" r="7" fill="#FFFFFF" stroke="#0C0C14" strokeWidth="1.8" filter="url(#appleHeroShadow)" />
|
||||||
|
|
||||||
|
{/* Distribution Hub 2 (340, 100) */}
|
||||||
|
<circle cx="340" cy="100" r="7" fill={isBypassIlluminated ? "#22C55E" : "#FFFFFF"} stroke={isBypassIlluminated ? "#22C55E" : "#0C0C14"} strokeWidth="1.8" filter="url(#appleHeroShadow)" />
|
||||||
|
|
||||||
|
{/* Distribution Hub 3 (480, 220) */}
|
||||||
|
<circle cx="480" cy="220" r="7" fill="#FFFFFF" stroke="#0C0C14" strokeWidth="1.8" filter="url(#appleHeroShadow)" />
|
||||||
|
|
||||||
|
{/* Destination 1 (North Target at 600, 100) */}
|
||||||
|
<circle cx="600" cy="100" r="8" fill="#FFFFFF" stroke="rgba(14,14,16,0.3)" strokeWidth="1.5" />
|
||||||
|
|
||||||
|
{/* Destination 2 (Main Target at 600, 220) */}
|
||||||
|
<g transform="translate(600, 220)">
|
||||||
|
<circle cx="0" cy="0" r="16" fill={isDelivered ? "rgba(34,197,94,0.18)" : "#FFFFFF"} stroke={isDelivered ? "#22C55E" : "#0C0C14"} strokeWidth="2" filter="url(#appleHeroShadow)" />
|
||||||
|
<circle cx="0" cy="0" r="5" fill={isDelivered ? "#22C55E" : "#0C0C14"} />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* Destination 3 (South Target at 600, 340) */}
|
||||||
|
<circle cx="600" cy="340" r="8" fill="#FFFFFF" stroke="rgba(14,14,16,0.3)" strokeWidth="1.5" />
|
||||||
|
|
||||||
|
{/* ============================================================
|
||||||
|
5. MULTIPLE DELIVERY VEHICLES MOVING SIMULTANEOUSLY
|
||||||
|
============================================================ */}
|
||||||
|
|
||||||
|
{/* VEHICLE 1 (PRIMARY FLEET UNIT - REROUTES UPON CONGESTION) */}
|
||||||
|
{(() => {
|
||||||
|
let vx = 80, vy = 220;
|
||||||
|
if (phase === 0) { vx = 80; vy = 220; }
|
||||||
|
else if (phase === 1) { vx = 140; vy = 220; }
|
||||||
|
else if (phase === 2) { vx = 200; vy = 220; } // Encounters traffic at Hub 1 (200, 220)
|
||||||
|
else if (phase === 3) { vx = 210; vy = 210; } // Network reacts
|
||||||
|
else if (phase === 4) { vx = 270; vy = 160; } // Smoothly curves onto Green Bypass Road
|
||||||
|
else if (phase === 5) { vx = 340; vy = 100; } // At Hub 2 Apex (340, 100)
|
||||||
|
else if (phase === 6) { vx = 410; vy = 160; } // Sweeping down to Hub 3 (480, 220)
|
||||||
|
else if (phase === 7) { vx = 480; vy = 220; } // At Hub 3
|
||||||
|
else if (phase >= 8) { vx = 600; vy = 220; } // Delivered at Destination 2
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g transform={`translate(${vx}, ${vy})`} className="vehicle-pod-smooth">
|
||||||
|
<circle cx="0" cy="0" r="13" fill={isBypassIlluminated ? "rgba(34, 197, 94, 0.28)" : "rgba(226, 53, 74, 0.25)"} />
|
||||||
|
<rect x="-9" y="-6" width="18" height="12" rx="2.5" fill="#0C0C14" stroke={isBypassIlluminated ? "#22C55E" : "#E2354A"} strokeWidth="1.5" filter="url(#appleHeroShadow)" />
|
||||||
|
<circle cx="4" cy="0" r="1.5" fill="#FFFFFF" />
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* VEHICLE 2 (NORTH ARTERIAL VAN - MOVES UNINTERRUPTED) */}
|
||||||
|
<g transform={`translate(${80 + ((phase * 52) % 520)}, 100)`} className="vehicle-pod-smooth">
|
||||||
|
<rect x="-8" y="-5" width="16" height="10" rx="2" fill="#0C0C14" stroke="rgba(255,255,255,0.8)" strokeWidth="1" />
|
||||||
|
<circle cx="3" cy="0" r="1" fill="#FFFFFF" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* VEHICLE 3 (SOUTH COURIER VAN - MOVES UNINTERRUPTED) */}
|
||||||
|
<g transform={`translate(${600 - ((phase * 48) % 520)}, 340)`} className="vehicle-pod-smooth">
|
||||||
|
<rect x="-8" y="-5" width="16" height="10" rx="2" fill="#0C0C14" stroke="rgba(255,255,255,0.8)" strokeWidth="1" />
|
||||||
|
<circle cx="-3" cy="0" r="1" fill="#FFFFFF" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* VEHICLE 4 (CROSS-TOWN RELAY VAN) */}
|
||||||
|
<g transform={`translate(340, ${380 - ((phase * 32) % 320)})`} className="vehicle-pod-smooth">
|
||||||
|
<rect x="-5" y="-8" width="10" height="16" rx="2" fill="#0C0C14" stroke="rgba(255,255,255,0.8)" strokeWidth="1" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* VEHICLE 5 (RING EXPRESSWAY SHUTTLE) */}
|
||||||
|
<g transform={`translate(${480 - ((phase * 28) % 280)}, 340)`} className="vehicle-pod-smooth">
|
||||||
|
<circle cx="0" cy="0" r="4.5" fill="#0C0C14" stroke="#E2354A" strokeWidth="1" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* ============================================================
|
||||||
|
6. APPLE-STYLE MINIMAL BADGES (ETA & SLA CONFIRMATION)
|
||||||
|
============================================================ */}
|
||||||
|
|
||||||
|
{/* Live ETA Badge (Top Right) */}
|
||||||
|
<g transform="translate(485, 30)" className="apple-pill-float">
|
||||||
|
<rect x="0" y="0" width="165" height="42" rx="21" fill="#FFFFFF" stroke={isEtaImproved ? "#22C55E" : "rgba(14,14,16,0.15)"} strokeWidth="1.5" filter="url(#appleHeroShadow)" />
|
||||||
|
<circle cx="21" cy="21" r="5" fill={isEtaImproved ? "#22C55E" : "#E2354A"} />
|
||||||
|
<text x="34" y="25" fill="#0E0E12" fontSize="13" fontWeight="800" fontFamily="sans-serif">
|
||||||
|
{isEtaImproved ? "41 min" : isTrafficActive ? "58 min" : "45 min"}
|
||||||
|
</text>
|
||||||
|
{isEtaImproved && (
|
||||||
|
<text x="96" y="25" fill="#22C55E" fontSize="9.5" fontWeight="800" fontFamily="sans-serif">
|
||||||
|
· Saved 17m
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* SLA Protected Pill (Appears at Step 9 Post-Reroute) */}
|
||||||
|
{isSlaProtected && (
|
||||||
|
<g transform="translate(460, 275)" className="apple-pill-float">
|
||||||
|
<rect x="0" y="0" width="165" height="32" rx="16" fill="#FFFFFF" stroke="#22C55E" strokeWidth="1.2" filter="url(#appleHeroShadow)" />
|
||||||
|
<circle cx="16" cy="16" r="4" fill="#22C55E" />
|
||||||
|
<text x="28" y="20" fill="#0E0E12" fontSize="10" fontWeight="800" fontFamily="sans-serif">
|
||||||
|
✓ SLA Protected (99.2%)
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
143
src/components/sections/DeliveredCounter.tsx
Normal file
143
src/components/sections/DeliveredCounter.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
DELIVERED COUNTER — Solutions page
|
||||||
|
|
||||||
|
The big outlined stat that, in the Logico reference, sits between the
|
||||||
|
mile cards and the "what we do" carousel: a large count-up number over a
|
||||||
|
dotted world map, with a rotated caption alongside it.
|
||||||
|
|
||||||
|
Rebuilt with Doormile branding — the site's red accent instead of teal,
|
||||||
|
the project's own dotted-map asset (public/images/bg-map.png), and a
|
||||||
|
logistics stat. It renders INSIDE the Miles3 dark card (as a sibling of
|
||||||
|
the carousel), so it reads as part of the same section. Transparent
|
||||||
|
surface; everything scoped to #dcount-root.
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
const TARGET = 24_836_517;
|
||||||
|
const DURATION = 2200;
|
||||||
|
|
||||||
|
/** de-DE groups thousands with ".", matching the reference's 358.895.941. */
|
||||||
|
function format(n: number): string {
|
||||||
|
return Math.round(n).toLocaleString("de-DE");
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DeliveredCounter() {
|
||||||
|
const rootRef = useRef<HTMLElement>(null);
|
||||||
|
const [val, setVal] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = rootRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
let raf = 0;
|
||||||
|
let fired = false;
|
||||||
|
|
||||||
|
const io = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
const e = entries[0];
|
||||||
|
if (!e.isIntersecting || fired) return;
|
||||||
|
fired = true;
|
||||||
|
io.disconnect();
|
||||||
|
if (reduce) { setVal(TARGET); return; }
|
||||||
|
const start = performance.now();
|
||||||
|
const tick = (now: number) => {
|
||||||
|
const p = Math.min(1, (now - start) / DURATION);
|
||||||
|
const eased = 1 - Math.pow(1 - p, 3); // easeOutCubic
|
||||||
|
setVal(TARGET * eased);
|
||||||
|
if (p < 1) raf = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
raf = requestAnimationFrame(tick);
|
||||||
|
},
|
||||||
|
{ threshold: 0.35 }
|
||||||
|
);
|
||||||
|
|
||||||
|
io.observe(el);
|
||||||
|
return () => { io.disconnect(); cancelAnimationFrame(raf); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<style dangerouslySetInnerHTML={{ __html: CSS }} />
|
||||||
|
<section id="dcount-root" ref={rootRef} aria-label="Deliveries completed to date">
|
||||||
|
<span className="dc-map" aria-hidden="true" />
|
||||||
|
<div className="dc-fig">
|
||||||
|
<span className="dc-num">{format(val)}</span>
|
||||||
|
<span className="dc-label" aria-hidden="true">
|
||||||
|
<b>Parcels delivered</b>
|
||||||
|
<i>end to end</i>
|
||||||
|
</span>
|
||||||
|
<span className="dc-sr">
|
||||||
|
{format(TARGET)} parcels delivered end to end
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const CSS = `
|
||||||
|
#dcount-root {
|
||||||
|
--pc-red-bright: #E2354A;
|
||||||
|
position: relative;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
#dcount-root, #dcount-root * { font-family: var(--font-manrope), "Manrope", sans-serif; box-sizing: border-box; }
|
||||||
|
|
||||||
|
/* Dotted world map backdrop — naturally overlays the statistic counter */
|
||||||
|
#dcount-root .dc-map {
|
||||||
|
position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
|
||||||
|
width: min(1300px, 98%); height: 100%; min-height: 380px; z-index: 0; pointer-events: none;
|
||||||
|
background: url(/images/bg-map.png) center / contain no-repeat;
|
||||||
|
filter: invert(1) brightness(2.2);
|
||||||
|
opacity: 0.18;
|
||||||
|
-webkit-mask-image: radial-gradient(85% 85% at 50% 50%, #000 50%, transparent 100%);
|
||||||
|
mask-image: radial-gradient(85% 85% at 50% 50%, #000 50%, transparent 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
#dcount-root .dc-fig {
|
||||||
|
position: relative; z-index: 1;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center; gap: clamp(14px, 1.8vw, 30px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Big outlined numeral, red stroke, transparent fill */
|
||||||
|
#dcount-root .dc-num {
|
||||||
|
color: transparent;
|
||||||
|
-webkit-text-stroke: 2px var(--pc-red-bright);
|
||||||
|
font-size: clamp(58px, 12.5vw, 200px);
|
||||||
|
font-weight: 500; line-height: 0.92; letter-spacing: -0.01em;
|
||||||
|
font-variant-numeric: tabular-nums; white-space: nowrap;
|
||||||
|
text-shadow: 0 0 40px rgba(226,53,74,0.18);
|
||||||
|
}
|
||||||
|
/* Firefox/older engines without text-stroke: fall back to a faint red fill so
|
||||||
|
the number never disappears. */
|
||||||
|
@supports not ((-webkit-text-stroke: 2px red)) {
|
||||||
|
#dcount-root .dc-num { color: rgba(226,53,74,0.85); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rotated caption, reading bottom-to-top like the reference */
|
||||||
|
#dcount-root .dc-label {
|
||||||
|
writing-mode: vertical-rl; transform: rotate(180deg);
|
||||||
|
display: inline-flex; align-items: center; gap: 10px;
|
||||||
|
color: #fff; line-height: 1.15; padding-bottom: 4px;
|
||||||
|
}
|
||||||
|
#dcount-root .dc-label b { font-size: clamp(12px, 1.1vw, 15px); font-weight: 700; letter-spacing: 0.02em; }
|
||||||
|
#dcount-root .dc-label i { font-style: normal; color: rgba(255,255,255,0.5); font-size: clamp(11px, 1vw, 14px); font-weight: 600; }
|
||||||
|
|
||||||
|
/* Screen-reader-only final figure (the visible number animates via aria-hidden fig) */
|
||||||
|
#dcount-root .dc-sr {
|
||||||
|
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
|
||||||
|
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 639px) {
|
||||||
|
#dcount-root { padding: 24px 0 20px; }
|
||||||
|
#dcount-root .dc-num { -webkit-text-stroke-width: 1.5px; }
|
||||||
|
#dcount-root .dc-fig { gap: 12px; }
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -343,7 +343,7 @@ function DashboardPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function EVSection({
|
export default function EVSection({
|
||||||
bannerImage = "/images/bg-header-5.webp",
|
bannerImage = "/images/ev.webp",
|
||||||
cardNumber = "",
|
cardNumber = "",
|
||||||
cardTitle = "EV Logistics",
|
cardTitle = "EV Logistics",
|
||||||
cardSubtitle = "Cleaner miles, lower costs",
|
cardSubtitle = "Cleaner miles, lower costs",
|
||||||
@@ -351,7 +351,7 @@ export default function EVSection({
|
|||||||
titleLead = "BUILT FOR ELECTRIC. ",
|
titleLead = "BUILT FOR ELECTRIC. ",
|
||||||
titleAccent = "NOT ADAPTED.",
|
titleAccent = "NOT ADAPTED.",
|
||||||
features = DEFAULT_FEATURES,
|
features = DEFAULT_FEATURES,
|
||||||
image = "/images/premium-ev-van.webp",
|
image = "/images/ev.webp",
|
||||||
imageAlt = "DoorMile electric fleet van",
|
imageAlt = "DoorMile electric fleet van",
|
||||||
badges = DEFAULT_BADGES,
|
badges = DEFAULT_BADGES,
|
||||||
stats = DEFAULT_STATS,
|
stats = DEFAULT_STATS,
|
||||||
@@ -768,20 +768,30 @@ export default function EVSection({
|
|||||||
.evnd-feature__icon-container {
|
.evnd-feature__icon-container {
|
||||||
width: 48px; height: 48px;
|
width: 48px; height: 48px;
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
background: rgba(255,255,255,0.03);
|
background: rgba(239, 68, 68, 0.14);
|
||||||
border: 1px solid rgba(255,255,255,0.08);
|
border: 1px solid rgba(239, 68, 68, 0.35);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
transition: background-color 0.3s ease, border-color 0.3s ease;
|
color: #ef4444 !important;
|
||||||
|
transition: background-color 0.3s ease, border-color 0.3s ease, color 0.3s ease;
|
||||||
}
|
}
|
||||||
.evnd-feature:hover .evnd-feature__icon-container {
|
.evnd-feature:hover .evnd-feature__icon-container {
|
||||||
background: rgba(239,68,68,0.08);
|
background: rgba(239, 68, 68, 0.25);
|
||||||
border-color: rgba(239,68,68,0.25);
|
border-color: rgba(239, 68, 68, 0.6);
|
||||||
|
color: #ff5555 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.evnd-icon {
|
.evnd-icon {
|
||||||
width: 22px;
|
width: 22px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
display: block;
|
display: block;
|
||||||
|
color: #ef4444 !important;
|
||||||
|
stroke: #ef4444 !important;
|
||||||
|
}
|
||||||
|
.evnd-icon path,
|
||||||
|
.evnd-icon circle,
|
||||||
|
.evnd-icon polygon,
|
||||||
|
.evnd-icon rect {
|
||||||
|
stroke: #ef4444 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.evnd-feature__title {
|
.evnd-feature__title {
|
||||||
@@ -1297,7 +1307,7 @@ export default function EVSection({
|
|||||||
data-settings='{"background_background":"classic"}'
|
data-settings='{"background_background":"classic"}'
|
||||||
style={{
|
style={{
|
||||||
backgroundPosition: "center 0px",
|
backgroundPosition: "center 0px",
|
||||||
backgroundImage: `url(${bannerImage})`,
|
backgroundImage: `url("${encodeURI(bannerImage)}")`,
|
||||||
backgroundSize: "cover",
|
backgroundSize: "cover",
|
||||||
backgroundRepeat: "no-repeat",
|
backgroundRepeat: "no-repeat",
|
||||||
position: "relative",
|
position: "relative",
|
||||||
|
|||||||
@@ -8,11 +8,20 @@ interface Miles3Props {
|
|||||||
eyebrow?: string;
|
eyebrow?: string;
|
||||||
/** Section heading. Defaults to the original How It Works copy. */
|
/** Section heading. Defaults to the original How It Works copy. */
|
||||||
heading?: string;
|
heading?: string;
|
||||||
|
/**
|
||||||
|
* Extra content rendered INSIDE the same dark card, directly below the
|
||||||
|
* First/Mid/Last Mile grid — so it reads as one continuous section rather
|
||||||
|
* than a new one. Used on the Solutions page for the Platform Capabilities
|
||||||
|
* carousel. When present, the card also gains a bottom pad so the appended
|
||||||
|
* block never touches the rounded edge.
|
||||||
|
*/
|
||||||
|
children?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Miles3({
|
export default function Miles3({
|
||||||
eyebrow = "/ How It Works /",
|
eyebrow = "/ How It Works /",
|
||||||
heading = "Doormile connects first, mid, and last mile into a seamless end-to-end logistics experience",
|
heading = "Doormile connects first, mid, and last mile into a seamless end-to-end logistics experience",
|
||||||
|
children,
|
||||||
}: Miles3Props = {}) {
|
}: Miles3Props = {}) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -31,6 +40,7 @@ export default function Miles3({
|
|||||||
.elementor-element-c36a604 {
|
.elementor-element-c36a604 {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
width: auto;
|
width: auto;
|
||||||
margin: 20px 20px 0 20px;
|
margin: 20px 20px 0 20px;
|
||||||
background-color: #1F1F1F;
|
background-color: #1F1F1F;
|
||||||
@@ -49,6 +59,7 @@ export default function Miles3({
|
|||||||
.elementor-element-77d1265 {
|
.elementor-element-77d1265 {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 1630px;
|
max-width: 1630px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
@@ -114,6 +125,13 @@ export default function Miles3({
|
|||||||
not grid-template-columns directly, or the grid never collapses on mobile. */
|
not grid-template-columns directly, or the grid never collapses on mobile. */
|
||||||
--e-con-grid-template-columns: repeat(3, 1fr);
|
--e-con-grid-template-columns: repeat(3, 1fr);
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
/* Same trap for the ROWS: ".e-con.e-grid" also drives grid-template-rows
|
||||||
|
from a var that defaults to repeat(2, 1fr), reserving a second, EMPTY
|
||||||
|
row (plus the 70px row-gap) under the 3 mile cards — ~380px of dead
|
||||||
|
space before whatever follows in the card. "auto" sizes the one real
|
||||||
|
row to its content and still lets the 2-col/1-col breakpoints create
|
||||||
|
implicit rows. */
|
||||||
|
--e-con-grid-template-rows: auto;
|
||||||
grid-auto-flow: row;
|
grid-auto-flow: row;
|
||||||
gap: 70px 60px;
|
gap: 70px 60px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -171,6 +189,22 @@ export default function Miles3({
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Continuation block (Platform Capabilities carousel) — lives inside the
|
||||||
|
same dark card, below the mile grid. Its own top border + generous
|
||||||
|
top/bottom rhythm keep it reading as "more of this section". The
|
||||||
|
card itself has no bottom padding, so this block owns the bottom
|
||||||
|
breathing room too. */
|
||||||
|
.miles3-continue {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
align-self: stretch;
|
||||||
|
margin-top: 28px;
|
||||||
|
padding-bottom: clamp(60px, 6vw, 90px);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- Responsive (grid breakpoints mirror the original section-miles3
|
/* ---- Responsive (grid breakpoints mirror the original section-miles3
|
||||||
cascade: 3-col@120gap >1200, 3-col@40gap ≤1200, 2-col ≤1020, 1-col
|
cascade: 3-col@120gap >1200, 3-col@40gap ≤1200, 2-col ≤1020, 1-col
|
||||||
≤480). The ≤1200 step is what keeps columns wide enough at laptop
|
≤480). The ≤1200 step is what keeps columns wide enough at laptop
|
||||||
@@ -282,6 +316,8 @@ export default function Miles3({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{children ? <div className="miles3-continue">{children}</div> : null}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
332
src/components/sections/NeuralField.tsx
Normal file
332
src/components/sections/NeuralField.tsx
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
NeuralField — the animated neural-network + particle backdrop behind the
|
||||||
|
MileTruth™ AI band on /solutions.
|
||||||
|
|
||||||
|
Deliberately a plain 2D canvas rather than the R3F MileTruthCanvas scene:
|
||||||
|
this one sits *behind* body copy at low opacity, so it has to stay cheap
|
||||||
|
enough to run under the fold of a long marketing page. It follows the same
|
||||||
|
rules as the other canvases on the site (WorkflowScene, MileTruthNetwork):
|
||||||
|
|
||||||
|
· DPR-aware, resized from the parent box (ResizeObserver)
|
||||||
|
· rAF paused while off-screen and while the tab is hidden
|
||||||
|
· prefers-reduced-motion paints ONE static frame and stops
|
||||||
|
|
||||||
|
Three layers, back to front:
|
||||||
|
1. particle field — tiny drifting motes, no links
|
||||||
|
2. neural mesh — drifting nodes + proximity links, accent-tinted as
|
||||||
|
the two ends get closer
|
||||||
|
3. signals — a few bright packets travelling node-to-node, which
|
||||||
|
is what reads as "a decision engine thinking"
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
type Node = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
vx: number;
|
||||||
|
vy: number;
|
||||||
|
r: number;
|
||||||
|
hub: boolean;
|
||||||
|
/** phase offset so hub glow does not pulse in lockstep */
|
||||||
|
ph: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Mote = { x: number; y: number; vy: number; sway: number; ph: number; a: number };
|
||||||
|
|
||||||
|
type Signal = { a: number; b: number; t: number; sp: number };
|
||||||
|
|
||||||
|
/** Distance at which two nodes stop being linked (CSS px). */
|
||||||
|
const LINK = 132;
|
||||||
|
/** Pointer radius that brightens the mesh. */
|
||||||
|
const CURSOR = 170;
|
||||||
|
|
||||||
|
function hexToRgb(hex: string): [number, number, number] {
|
||||||
|
const h = hex.replace("#", "");
|
||||||
|
const full = h.length === 3 ? h.split("").map((c) => c + c).join("") : h;
|
||||||
|
const n = parseInt(full, 16);
|
||||||
|
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NeuralField({
|
||||||
|
accent = "#E2354A",
|
||||||
|
tone = "dark",
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
accent?: string;
|
||||||
|
/** Surface the field is painted on. "light" inks the mesh near-black instead
|
||||||
|
* of white and drops the additive glow, which is invisible on paper. */
|
||||||
|
tone?: "dark" | "light";
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const parent = canvas?.parentElement;
|
||||||
|
if (!canvas || !parent) return;
|
||||||
|
const ctx = canvas.getContext("2d", { alpha: true });
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const [ar, ag, ab] = hexToRgb(accent);
|
||||||
|
const light = tone === "light";
|
||||||
|
// The ink the mesh fades towards when a link is long / far from the pointer.
|
||||||
|
const [ir, ig, ib] = light ? [17, 17, 17] : [255, 255, 255];
|
||||||
|
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
|
||||||
|
let w = 0;
|
||||||
|
let h = 0;
|
||||||
|
let nodes: Node[] = [];
|
||||||
|
let motes: Mote[] = [];
|
||||||
|
let signals: Signal[] = [];
|
||||||
|
let raf = 0;
|
||||||
|
let visible = true;
|
||||||
|
let running = false;
|
||||||
|
let last = 0;
|
||||||
|
let nextSignal = 600;
|
||||||
|
const pointer = { x: -9999, y: -9999 };
|
||||||
|
|
||||||
|
const rand = (a: number, b: number) => a + Math.random() * (b - a);
|
||||||
|
|
||||||
|
function build() {
|
||||||
|
const area = w * h;
|
||||||
|
// One node per ~24k css px², clamped — enough to read as a mesh at
|
||||||
|
// 1440px wide without turning the O(n²) link pass into real work.
|
||||||
|
const count = Math.max(16, Math.min(72, Math.round(area / 24000)));
|
||||||
|
nodes = Array.from({ length: count }, (_, i) => ({
|
||||||
|
x: Math.random() * w,
|
||||||
|
y: Math.random() * h,
|
||||||
|
vx: rand(-0.14, 0.14),
|
||||||
|
vy: rand(-0.11, 0.11),
|
||||||
|
r: rand(1.1, 2.3),
|
||||||
|
hub: i % 9 === 0,
|
||||||
|
ph: Math.random() * Math.PI * 2,
|
||||||
|
}));
|
||||||
|
motes = Array.from({ length: Math.min(140, Math.round(area / 9000)) }, () => ({
|
||||||
|
x: Math.random() * w,
|
||||||
|
y: Math.random() * h,
|
||||||
|
vy: rand(-0.22, -0.06),
|
||||||
|
sway: rand(6, 20),
|
||||||
|
ph: Math.random() * Math.PI * 2,
|
||||||
|
a: rand(0.06, 0.28),
|
||||||
|
}));
|
||||||
|
signals = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function resize() {
|
||||||
|
const rect = parent!.getBoundingClientRect();
|
||||||
|
if (!rect.width || !rect.height) return;
|
||||||
|
const dpr = Math.min(2, window.devicePixelRatio || 1);
|
||||||
|
w = rect.width;
|
||||||
|
h = rect.height;
|
||||||
|
canvas!.width = Math.round(w * dpr);
|
||||||
|
canvas!.height = Math.round(h * dpr);
|
||||||
|
canvas!.style.width = w + "px";
|
||||||
|
canvas!.style.height = h + "px";
|
||||||
|
ctx!.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
build();
|
||||||
|
if (reduce) draw(0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnSignal() {
|
||||||
|
if (nodes.length < 2 || signals.length >= 3) return;
|
||||||
|
const a = Math.floor(Math.random() * nodes.length);
|
||||||
|
// Prefer a neighbour that is actually linked, so the packet rides a
|
||||||
|
// visible edge instead of crossing empty space.
|
||||||
|
let b = -1;
|
||||||
|
let best = Infinity;
|
||||||
|
for (let i = 0; i < nodes.length; i++) {
|
||||||
|
if (i === a) continue;
|
||||||
|
const d = Math.hypot(nodes[i].x - nodes[a].x, nodes[i].y - nodes[a].y);
|
||||||
|
if (d < LINK && d < best && Math.random() > 0.45) {
|
||||||
|
best = d;
|
||||||
|
b = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (b < 0) return;
|
||||||
|
signals.push({ a, b, t: 0, sp: rand(0.5, 0.95) });
|
||||||
|
}
|
||||||
|
|
||||||
|
function draw(dt: number, time: number) {
|
||||||
|
ctx!.clearRect(0, 0, w, h);
|
||||||
|
|
||||||
|
/* ---- 1. particle field ---- */
|
||||||
|
for (const m of motes) {
|
||||||
|
ctx!.beginPath();
|
||||||
|
ctx!.arc(m.x + Math.sin(time * 0.0004 + m.ph) * m.sway, m.y, 0.9, 0, Math.PI * 2);
|
||||||
|
ctx!.fillStyle = "rgba(" + ir + "," + ig + "," + ib + "," + m.a * (light ? 0.4 : 0.55) + ")";
|
||||||
|
ctx!.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 2. neural mesh ---- */
|
||||||
|
ctx!.lineWidth = 1;
|
||||||
|
for (let i = 0; i < nodes.length; i++) {
|
||||||
|
const a = nodes[i];
|
||||||
|
for (let j = i + 1; j < nodes.length; j++) {
|
||||||
|
const b = nodes[j];
|
||||||
|
const dx = a.x - b.x;
|
||||||
|
const dy = a.y - b.y;
|
||||||
|
const d2 = dx * dx + dy * dy;
|
||||||
|
if (d2 > LINK * LINK) continue;
|
||||||
|
const d = Math.sqrt(d2);
|
||||||
|
const prox = 1 - d / LINK;
|
||||||
|
// Near the pointer the mesh warms towards the accent and brightens.
|
||||||
|
const mx = (a.x + b.x) * 0.5;
|
||||||
|
const my = (a.y + b.y) * 0.5;
|
||||||
|
const cur = Math.max(0, 1 - Math.hypot(mx - pointer.x, my - pointer.y) / CURSOR);
|
||||||
|
const tint = Math.min(1, prox * 0.85 + cur * 0.6);
|
||||||
|
const alpha = (prox * 0.3 + cur * 0.35) * (light ? 0.6 : 1);
|
||||||
|
const r = Math.round(ir + (ar - ir) * tint);
|
||||||
|
const g = Math.round(ig + (ag - ig) * tint);
|
||||||
|
const bl = Math.round(ib + (ab - ib) * tint);
|
||||||
|
ctx!.strokeStyle = "rgba(" + r + "," + g + "," + bl + "," + alpha.toFixed(3) + ")";
|
||||||
|
ctx!.beginPath();
|
||||||
|
ctx!.moveTo(a.x, a.y);
|
||||||
|
ctx!.lineTo(b.x, b.y);
|
||||||
|
ctx!.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const n of nodes) {
|
||||||
|
const cur = Math.max(0, 1 - Math.hypot(n.x - pointer.x, n.y - pointer.y) / CURSOR);
|
||||||
|
if (n.hub) {
|
||||||
|
const pulse = 0.5 + 0.5 * Math.sin(time * 0.0016 + n.ph);
|
||||||
|
ctx!.save();
|
||||||
|
// An additive bloom only reads on a dark surface; on paper it muddies.
|
||||||
|
if (!light) {
|
||||||
|
ctx!.shadowColor = "rgba(" + ar + "," + ag + "," + ab + ",0.9)";
|
||||||
|
ctx!.shadowBlur = 10 + pulse * 12;
|
||||||
|
}
|
||||||
|
ctx!.beginPath();
|
||||||
|
ctx!.arc(n.x, n.y, n.r + 0.7 + pulse * 0.6, 0, Math.PI * 2);
|
||||||
|
ctx!.fillStyle = "rgba(" + ar + "," + ag + "," + ab + "," + (0.5 + pulse * 0.35) * (light ? 0.7 : 1) + ")";
|
||||||
|
ctx!.fill();
|
||||||
|
ctx!.restore();
|
||||||
|
} else {
|
||||||
|
ctx!.beginPath();
|
||||||
|
ctx!.arc(n.x, n.y, n.r, 0, Math.PI * 2);
|
||||||
|
ctx!.fillStyle = "rgba(" + ir + "," + ig + "," + ib + "," + (0.16 + cur * 0.4) * (light ? 0.55 : 1) + ")";
|
||||||
|
ctx!.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 3. travelling signals ---- */
|
||||||
|
for (const s of signals) {
|
||||||
|
const a = nodes[s.a];
|
||||||
|
const b = nodes[s.b];
|
||||||
|
if (!a || !b) continue;
|
||||||
|
const x = a.x + (b.x - a.x) * s.t;
|
||||||
|
const y = a.y + (b.y - a.y) * s.t;
|
||||||
|
// Fade in and out so packets never pop at the endpoints.
|
||||||
|
const fade = Math.sin(s.t * Math.PI);
|
||||||
|
ctx!.save();
|
||||||
|
if (!light) {
|
||||||
|
ctx!.shadowColor = "rgba(" + ar + "," + ag + "," + ab + ",1)";
|
||||||
|
ctx!.shadowBlur = 14;
|
||||||
|
}
|
||||||
|
ctx!.beginPath();
|
||||||
|
ctx!.arc(x, y, 1.9, 0, Math.PI * 2);
|
||||||
|
// On paper the packet is the accent itself; on dark it burns towards white.
|
||||||
|
ctx!.fillStyle = light
|
||||||
|
? "rgba(" + ar + "," + ag + "," + ab + "," + fade + ")"
|
||||||
|
: "rgba(255," + Math.round(ag * 0.7 + 90) + "," + Math.round(ab * 0.7 + 90) + "," + fade + ")";
|
||||||
|
ctx!.fill();
|
||||||
|
ctx!.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reduce) return;
|
||||||
|
|
||||||
|
/* ---- integrate ---- */
|
||||||
|
const step = Math.min(3, dt / 16.67);
|
||||||
|
for (const n of nodes) {
|
||||||
|
n.x += n.vx * step;
|
||||||
|
n.y += n.vy * step;
|
||||||
|
if (n.x < -20) n.x = w + 20;
|
||||||
|
if (n.x > w + 20) n.x = -20;
|
||||||
|
if (n.y < -20) n.y = h + 20;
|
||||||
|
if (n.y > h + 20) n.y = -20;
|
||||||
|
}
|
||||||
|
for (const m of motes) {
|
||||||
|
m.y += m.vy * step;
|
||||||
|
if (m.y < -10) {
|
||||||
|
m.y = h + 10;
|
||||||
|
m.x = Math.random() * w;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let i = signals.length - 1; i >= 0; i--) {
|
||||||
|
signals[i].t += (signals[i].sp * step) / 60;
|
||||||
|
if (signals[i].t >= 1) signals.splice(i, 1);
|
||||||
|
}
|
||||||
|
nextSignal -= dt;
|
||||||
|
if (nextSignal <= 0) {
|
||||||
|
spawnSignal();
|
||||||
|
nextSignal = rand(700, 1800);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function frame(time: number) {
|
||||||
|
const dt = last ? time - last : 16.67;
|
||||||
|
last = time;
|
||||||
|
draw(dt, time);
|
||||||
|
raf = requestAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
if (running || reduce) return;
|
||||||
|
running = true;
|
||||||
|
last = 0;
|
||||||
|
raf = requestAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
function stop() {
|
||||||
|
running = false;
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ro = new ResizeObserver(resize);
|
||||||
|
ro.observe(parent);
|
||||||
|
resize();
|
||||||
|
|
||||||
|
const io = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
visible = entries.some((e) => e.isIntersecting);
|
||||||
|
if (visible && !document.hidden) start();
|
||||||
|
else stop();
|
||||||
|
},
|
||||||
|
{ rootMargin: "120px 0px" },
|
||||||
|
);
|
||||||
|
io.observe(canvas);
|
||||||
|
|
||||||
|
const onVisibility = () => {
|
||||||
|
if (document.hidden) stop();
|
||||||
|
else if (visible) start();
|
||||||
|
};
|
||||||
|
const onMove = (e: PointerEvent) => {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
pointer.x = e.clientX - rect.left;
|
||||||
|
pointer.y = e.clientY - rect.top;
|
||||||
|
};
|
||||||
|
const onLeave = () => {
|
||||||
|
pointer.x = -9999;
|
||||||
|
pointer.y = -9999;
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("visibilitychange", onVisibility);
|
||||||
|
// Listener on the section (not the canvas — it is pointer-events: none).
|
||||||
|
const host = parent.closest("section") ?? parent;
|
||||||
|
host.addEventListener("pointermove", onMove as EventListener);
|
||||||
|
host.addEventListener("pointerleave", onLeave);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
stop();
|
||||||
|
ro.disconnect();
|
||||||
|
io.disconnect();
|
||||||
|
document.removeEventListener("visibilitychange", onVisibility);
|
||||||
|
host.removeEventListener("pointermove", onMove as EventListener);
|
||||||
|
host.removeEventListener("pointerleave", onLeave);
|
||||||
|
};
|
||||||
|
}, [accent, tone]);
|
||||||
|
|
||||||
|
return <canvas ref={canvasRef} className={className} aria-hidden="true" />;
|
||||||
|
}
|
||||||
1124
src/components/sections/PlatformCapabilities.tsx
Normal file
1124
src/components/sections/PlatformCapabilities.tsx
Normal file
File diff suppressed because it is too large
Load Diff
917
src/components/sections/SolutionsMileTruthAnimation.tsx
Normal file
917
src/components/sections/SolutionsMileTruthAnimation.tsx
Normal file
@@ -0,0 +1,917 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
|
||||||
|
interface SolutionsMileTruthAnimationProps {
|
||||||
|
variant?: "compact" | "full";
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------
|
||||||
|
Palette — ink + Doormile red, and nothing else.
|
||||||
|
|
||||||
|
Red carries everything the AI is responsible for (the core, the
|
||||||
|
optimised route, the confirmed outcomes). Ink at low opacity carries
|
||||||
|
structure and anything the AI rejected. There is deliberately no third
|
||||||
|
hue: an enterprise console reads as premium because it is restrained,
|
||||||
|
not because it is colourful.
|
||||||
|
------------------------------------------------------------------ */
|
||||||
|
const INK = "#0E0E12";
|
||||||
|
const RED = "#E2354A";
|
||||||
|
const RED_DEEP = "#C01227";
|
||||||
|
|
||||||
|
/* ==================================================================
|
||||||
|
LAYER GEOMETRY — one 680×400 board, three columns.
|
||||||
|
|
||||||
|
22 ┄┄ 158 inputs (live signals arriving)
|
||||||
|
206 ┄┄ 430 core (the engine deciding)
|
||||||
|
452 ┄┄ 658 outcome (what the decision protected)
|
||||||
|
|
||||||
|
The card frame, the viewBox and the wrapper's aspect-ratio are fixed;
|
||||||
|
only the interior is composed here.
|
||||||
|
================================================================== */
|
||||||
|
|
||||||
|
/** Layer 1. Six live feeds — enough to read as "many inputs" without the
|
||||||
|
* column turning into a list nobody scans. */
|
||||||
|
const INPUTS: { label: string; icon: React.ReactNode }[] = [
|
||||||
|
{
|
||||||
|
label: "Fleet GPS",
|
||||||
|
icon: (
|
||||||
|
<>
|
||||||
|
<path d="M8 13.2C8 13.2 12.6 9.3 12.6 6.5A4.6 4.6 0 1 0 3.4 6.5C3.4 9.3 8 13.2 8 13.2Z" />
|
||||||
|
<circle cx="8" cy="6.4" r="1.6" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Traffic flow",
|
||||||
|
icon: (
|
||||||
|
<>
|
||||||
|
<rect x="4.6" y="2.4" width="6.8" height="11.2" rx="2.4" />
|
||||||
|
<circle cx="8" cy="5.4" r="0.9" />
|
||||||
|
<circle cx="8" cy="8" r="0.9" />
|
||||||
|
<circle cx="8" cy="10.6" r="0.9" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Weather",
|
||||||
|
icon: (
|
||||||
|
<path d="M4.8 11.6A2.6 2.6 0 0 1 5.4 6.5 3.6 3.6 0 0 1 12 7.1 2.4 2.4 0 0 1 11.7 11.6Z" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Order load",
|
||||||
|
icon: (
|
||||||
|
<>
|
||||||
|
<path d="M3 5.4 8 2.8 13 5.4V11L8 13.6 3 11Z" />
|
||||||
|
<path d="M3 5.4 8 8m0 0 5-2.6M8 8v5.6" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Driver status",
|
||||||
|
icon: (
|
||||||
|
<>
|
||||||
|
<circle cx="8" cy="5.6" r="2.2" />
|
||||||
|
<path d="M3.9 13.2a4.1 4.1 0 0 1 8.2 0" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Vehicle health",
|
||||||
|
icon: <path d="M2.8 8h2.6L7 4.8 9.2 11.6 10.7 8h2.5" />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Layer 2. A four-stage network — the shape of a decision, not decoration. */
|
||||||
|
const CORE = { cx: 318, cy: 200 };
|
||||||
|
const NET: number[][] = [
|
||||||
|
[152, 200, 248], // inputs
|
||||||
|
[132, 178, 222, 268], // hidden A
|
||||||
|
[132, 178, 222, 268], // hidden B
|
||||||
|
[168, 232], // decisions
|
||||||
|
];
|
||||||
|
const NET_X = [236, 288, 346, 400];
|
||||||
|
|
||||||
|
/** The one path lit red — the decision actually taken this cycle. Each entry
|
||||||
|
* is [layer, fromIndex, toIndex]. */
|
||||||
|
const LIVE_PATH: [number, number, number][] = [
|
||||||
|
[0, 1, 1],
|
||||||
|
[0, 0, 0],
|
||||||
|
[1, 1, 2],
|
||||||
|
[1, 0, 1],
|
||||||
|
[2, 2, 0],
|
||||||
|
[2, 1, 0],
|
||||||
|
];
|
||||||
|
const liveKey = (l: number, a: number, b: number) => `${l}-${a}-${b}`;
|
||||||
|
const LIVE_SET = new Set(LIVE_PATH.map(([l, a, b]) => liveKey(l, a, b)));
|
||||||
|
|
||||||
|
/** Nodes that fire — one per layer, following LIVE_PATH. */
|
||||||
|
const ACTIVE_NODES = new Set(["0-1", "1-1", "2-2", "3-0"]);
|
||||||
|
|
||||||
|
/** Layer 3. What the decision protected, in the order ops would confirm it. */
|
||||||
|
const OUTCOMES = ["Delay avoided", "SLA protected", "Driver updated", "Customer notified"];
|
||||||
|
|
||||||
|
/** The header's cycling state — the engine narrating itself. */
|
||||||
|
const PHASES = ["Analysing", "Correlating", "Deciding", "Optimised"] as const;
|
||||||
|
|
||||||
|
/* The CSS below stills every animation under prefers-reduced-motion, but the
|
||||||
|
route pod is SMIL, which CSS cannot stop — so the preference is read here
|
||||||
|
too and the pod is simply not rendered. Subscribed rather than set from an
|
||||||
|
effect so it stays correct if the user flips the OS setting mid-session. */
|
||||||
|
const REDUCE_QUERY = "(prefers-reduced-motion: reduce)";
|
||||||
|
const subscribeReduce = (cb: () => void) => {
|
||||||
|
const mq = window.matchMedia(REDUCE_QUERY);
|
||||||
|
mq.addEventListener("change", cb);
|
||||||
|
return () => mq.removeEventListener("change", cb);
|
||||||
|
};
|
||||||
|
const useReducedMotion = () =>
|
||||||
|
React.useSyncExternalStore(
|
||||||
|
subscribeReduce,
|
||||||
|
() => window.matchMedia(REDUCE_QUERY).matches,
|
||||||
|
() => false
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function SolutionsMileTruthAnimation({
|
||||||
|
variant = "full",
|
||||||
|
className = "",
|
||||||
|
}: SolutionsMileTruthAnimationProps) {
|
||||||
|
const isCompact = variant === "compact";
|
||||||
|
|
||||||
|
const [phase, setPhase] = useState(0);
|
||||||
|
const [signals, setSignals] = useState(247);
|
||||||
|
const still = useReducedMotion();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isCompact || still) return;
|
||||||
|
const t = setInterval(() => setPhase((p) => (p + 1) % PHASES.length), 2400);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [isCompact, still]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isCompact || still) return;
|
||||||
|
// A live counter that drifts rather than spins — telemetry, not a slot machine.
|
||||||
|
const t = setInterval(() => setSignals(244 + Math.floor(Math.random() * 8)), 1600);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [isCompact, still]);
|
||||||
|
|
||||||
|
if (isCompact) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`sol-s05-svg-core ${className}`}
|
||||||
|
style={{ width: "100%", height: "100%", position: "relative" }}
|
||||||
|
>
|
||||||
|
<style jsx>{`
|
||||||
|
.sol-compact-route-dash {
|
||||||
|
stroke-dasharray: 4 6;
|
||||||
|
animation: solCDashFlow 2s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes solCDashFlow {
|
||||||
|
to {
|
||||||
|
stroke-dashoffset: -20;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.sol-compact-route-dash {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 100 100"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M 10 30 H 90 M 10 70 H 90 M 30 10 V 90 M 70 10 V 90"
|
||||||
|
stroke="rgba(14,14,16,0.1)"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M 30 70 C 45 30, 55 30, 70 30"
|
||||||
|
stroke={RED}
|
||||||
|
strokeWidth="2.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
className="sol-compact-route-dash"
|
||||||
|
/>
|
||||||
|
<circle cx="30" cy="70" r="4" fill={INK} />
|
||||||
|
<circle cx="70" cy="30" r="4" fill={RED} />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`sol-mt-console ${className}`}
|
||||||
|
style={{ position: "relative", width: "100%", height: "100%" }}
|
||||||
|
>
|
||||||
|
<style jsx>{`
|
||||||
|
.sol-mtc-svg {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Signal flow. Long gaps, slow cycle: data arriving, not a
|
||||||
|
barber's pole. ---- */
|
||||||
|
.sol-mtc-feed {
|
||||||
|
stroke-dasharray: 13 42;
|
||||||
|
animation: solMtcFeed 2.6s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes solMtcFeed {
|
||||||
|
to {
|
||||||
|
stroke-dashoffset: -55;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sol-mtc-synapse {
|
||||||
|
stroke-dasharray: 9 34;
|
||||||
|
animation: solMtcFeed 1.5s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sol-mtc-route-flow {
|
||||||
|
stroke-dasharray: 10 20;
|
||||||
|
animation: solMtcRouteFlow 1.6s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes solMtcRouteFlow {
|
||||||
|
to {
|
||||||
|
stroke-dashoffset: -30;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- The core: two counter-rotating rings and a breathing glow.
|
||||||
|
This is the only thing on the board allowed to move
|
||||||
|
continuously, because it is the thing that never stops. ---- */
|
||||||
|
.sol-mtc-ring {
|
||||||
|
transform-origin: 318px 200px;
|
||||||
|
animation: solMtcSpin 34s linear infinite;
|
||||||
|
}
|
||||||
|
.sol-mtc-ring--b {
|
||||||
|
animation-duration: 22s;
|
||||||
|
animation-direction: reverse;
|
||||||
|
}
|
||||||
|
@keyframes solMtcSpin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sol-mtc-glow {
|
||||||
|
transform-box: fill-box;
|
||||||
|
transform-origin: center;
|
||||||
|
animation: solMtcGlow 6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes solMtcGlow {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.55;
|
||||||
|
transform: scale(0.94);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1.04);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Firing node: a halo that expands and clears. ---- */
|
||||||
|
.sol-mtc-fire {
|
||||||
|
transform-box: fill-box;
|
||||||
|
transform-origin: center;
|
||||||
|
animation: solMtcFire 2.4s ease-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes solMtcFire {
|
||||||
|
0% {
|
||||||
|
opacity: 0.55;
|
||||||
|
transform: scale(0.5);
|
||||||
|
}
|
||||||
|
60%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(2.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Live dots on the input chips. ---- */
|
||||||
|
.sol-mtc-live {
|
||||||
|
transform-box: fill-box;
|
||||||
|
transform-origin: center;
|
||||||
|
animation: solMtcLive 2.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes solMtcLive {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Outcome ticks confirming in sequence. ---- */
|
||||||
|
.sol-mtc-tick {
|
||||||
|
transform-box: fill-box;
|
||||||
|
transform-origin: center;
|
||||||
|
animation: solMtcTick 4.8s ease-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes solMtcTick {
|
||||||
|
0% {
|
||||||
|
opacity: 0.5;
|
||||||
|
transform: scale(0.7);
|
||||||
|
}
|
||||||
|
35%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(1.9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sol-mtc-phase {
|
||||||
|
transition: fill 0.25s ease, stroke 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.sol-mtc-feed,
|
||||||
|
.sol-mtc-synapse,
|
||||||
|
.sol-mtc-route-flow,
|
||||||
|
.sol-mtc-ring,
|
||||||
|
.sol-mtc-glow,
|
||||||
|
.sol-mtc-fire,
|
||||||
|
.sol-mtc-live,
|
||||||
|
.sol-mtc-tick {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 680 400"
|
||||||
|
className="sol-mtc-svg"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
role="img"
|
||||||
|
aria-label="MileTruth AI decision engine: live telemetry feeds — fleet GPS, traffic, weather, order load, driver status and vehicle health — flow into a neural decision core, which returns an optimised route that avoids delay and holds the 99.2% SLA at a 41-minute ETA."
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="solMtcClip">
|
||||||
|
<rect x="8" y="8" width="664" height="384" rx="26" />
|
||||||
|
</clipPath>
|
||||||
|
|
||||||
|
<pattern id="solMtcDots" width="28" height="28" patternUnits="userSpaceOnUse">
|
||||||
|
<circle cx="14" cy="14" r="0.85" fill="rgba(14,14,16,0.055)" />
|
||||||
|
</pattern>
|
||||||
|
|
||||||
|
{/* Glass: the card is white, so depth comes from a barely-there
|
||||||
|
vertical wash rather than blur — anything stronger reads as dirt. */}
|
||||||
|
<linearGradient id="solMtcGlass" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="#FFFFFF" stopOpacity="0.9" />
|
||||||
|
<stop offset="60%" stopColor="#FFFFFF" stopOpacity="0" />
|
||||||
|
<stop offset="100%" stopColor={INK} stopOpacity="0.022" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<radialGradient id="solMtcCoreGlow">
|
||||||
|
<stop offset="0%" stopColor={RED} stopOpacity="0.16" />
|
||||||
|
<stop offset="45%" stopColor={RED} stopOpacity="0.06" />
|
||||||
|
<stop offset="100%" stopColor={RED} stopOpacity="0" />
|
||||||
|
</radialGradient>
|
||||||
|
|
||||||
|
<linearGradient id="solMtcRouteGrad" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stopColor={RED_DEEP} />
|
||||||
|
<stop offset="100%" stopColor={RED} />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<filter id="solMtcCardShadow" x="-12%" y="-14%" width="124%" height="132%">
|
||||||
|
<feDropShadow dx="0" dy="12" stdDeviation="16" floodColor={INK} floodOpacity="0.07" />
|
||||||
|
<feDropShadow dx="0" dy="2" stdDeviation="2" floodColor={INK} floodOpacity="0.05" />
|
||||||
|
</filter>
|
||||||
|
|
||||||
|
<filter id="solMtcChipShadow" x="-25%" y="-55%" width="150%" height="210%">
|
||||||
|
<feDropShadow dx="0" dy="1.5" stdDeviation="2.5" floodColor={INK} floodOpacity="0.07" />
|
||||||
|
</filter>
|
||||||
|
|
||||||
|
<filter id="solMtcNodeGlow" x="-160%" y="-160%" width="420%" height="420%">
|
||||||
|
<feGaussianBlur stdDeviation="2.4" />
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{/* ============ CARD ============ */}
|
||||||
|
<rect
|
||||||
|
x="8"
|
||||||
|
y="8"
|
||||||
|
width="664"
|
||||||
|
height="384"
|
||||||
|
rx="26"
|
||||||
|
fill="#FFFFFF"
|
||||||
|
stroke="rgba(14,14,16,0.09)"
|
||||||
|
strokeWidth="1"
|
||||||
|
filter="url(#solMtcCardShadow)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<g clipPath="url(#solMtcClip)">
|
||||||
|
<rect x="8" y="60" width="664" height="266" fill="url(#solMtcDots)" />
|
||||||
|
<rect x="8" y="8" width="664" height="384" fill="url(#solMtcGlass)" />
|
||||||
|
|
||||||
|
{/* ============ HEADER ============ */}
|
||||||
|
<circle cx="34" cy="34" r="4" fill={RED} />
|
||||||
|
<circle cx="34" cy="34" r="4" fill={RED} className="sol-mtc-fire" />
|
||||||
|
<text
|
||||||
|
x="47"
|
||||||
|
y="38"
|
||||||
|
fill="rgba(14,14,16,0.6)"
|
||||||
|
fontSize="11"
|
||||||
|
fontWeight="800"
|
||||||
|
letterSpacing="1.35"
|
||||||
|
fontFamily="Manrope, sans-serif"
|
||||||
|
>
|
||||||
|
MILETRUTH™ AI DECISION ENGINE
|
||||||
|
</text>
|
||||||
|
|
||||||
|
<g filter="url(#solMtcChipShadow)">
|
||||||
|
<rect
|
||||||
|
x="438"
|
||||||
|
y="20"
|
||||||
|
width="214"
|
||||||
|
height="28"
|
||||||
|
rx="14"
|
||||||
|
fill="#FFFFFF"
|
||||||
|
stroke="rgba(226,53,74,0.35)"
|
||||||
|
strokeWidth="1.1"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<circle cx="456" cy="34" r="4" fill={RED} />
|
||||||
|
<circle cx="456" cy="34" r="4" fill={RED} className="sol-mtc-live" />
|
||||||
|
<text
|
||||||
|
x="470"
|
||||||
|
y="38"
|
||||||
|
fill={INK}
|
||||||
|
fontSize="10.5"
|
||||||
|
fontWeight="800"
|
||||||
|
fontFamily="Manrope, sans-serif"
|
||||||
|
>
|
||||||
|
{PHASES[phase]}
|
||||||
|
<tspan fill="rgba(14,14,16,0.4)">{" · "}</tspan>
|
||||||
|
{signals} live signals
|
||||||
|
</text>
|
||||||
|
|
||||||
|
<line x1="8" y1="60" x2="672" y2="60" stroke="rgba(14,14,16,0.07)" strokeWidth="1" />
|
||||||
|
|
||||||
|
{/* ============ COLUMN LABELS ============ */}
|
||||||
|
{[
|
||||||
|
{ x: 91, t: "LIVE INPUTS" },
|
||||||
|
{ x: 318, t: "NEURAL CORE" },
|
||||||
|
{ x: 555, t: "PROTECTED OUTCOME" },
|
||||||
|
].map((c) => (
|
||||||
|
<text
|
||||||
|
key={c.t}
|
||||||
|
x={c.x}
|
||||||
|
y="84"
|
||||||
|
textAnchor="middle"
|
||||||
|
fill="rgba(14,14,16,0.38)"
|
||||||
|
fontSize="9.5"
|
||||||
|
fontWeight="800"
|
||||||
|
letterSpacing="1.25"
|
||||||
|
fontFamily="Manrope, sans-serif"
|
||||||
|
>
|
||||||
|
{c.t}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* ============================================================
|
||||||
|
LAYER 1 — LIVE INPUTS
|
||||||
|
============================================================ */}
|
||||||
|
{INPUTS.map((inp, i) => {
|
||||||
|
const top = 96 + i * 37;
|
||||||
|
return (
|
||||||
|
<g key={inp.label}>
|
||||||
|
<g filter="url(#solMtcChipShadow)">
|
||||||
|
<rect
|
||||||
|
x="24"
|
||||||
|
y={top}
|
||||||
|
width="134"
|
||||||
|
height="26"
|
||||||
|
rx="9"
|
||||||
|
fill="#FFFFFF"
|
||||||
|
stroke="rgba(14,14,16,0.085)"
|
||||||
|
strokeWidth="1"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<rect
|
||||||
|
x="32"
|
||||||
|
y={top + 5}
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
rx="5.5"
|
||||||
|
fill="rgba(226,53,74,0.08)"
|
||||||
|
/>
|
||||||
|
<g
|
||||||
|
transform={`translate(32, ${top + 5})`}
|
||||||
|
stroke={RED_DEEP}
|
||||||
|
strokeWidth="1.15"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
fill="none"
|
||||||
|
>
|
||||||
|
{inp.icon}
|
||||||
|
</g>
|
||||||
|
<text
|
||||||
|
x="56"
|
||||||
|
y={top + 17}
|
||||||
|
fill="rgba(14,14,16,0.78)"
|
||||||
|
fontSize="9.5"
|
||||||
|
fontWeight="700"
|
||||||
|
fontFamily="Manrope, sans-serif"
|
||||||
|
>
|
||||||
|
{inp.label}
|
||||||
|
</text>
|
||||||
|
<circle cx="147" cy={top + 13} r="2.6" fill={RED} fillOpacity="0.25" />
|
||||||
|
<circle
|
||||||
|
cx="147"
|
||||||
|
cy={top + 13}
|
||||||
|
r="2.6"
|
||||||
|
fill={RED}
|
||||||
|
className="sol-mtc-live"
|
||||||
|
style={{ animationDelay: `${i * 0.32}s` }}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Feeds converging on the core's input layer. Two chips per input
|
||||||
|
node, which is why the column reads as convergence. */}
|
||||||
|
{INPUTS.map((inp, i) => {
|
||||||
|
const cy = 96 + i * 37 + 13;
|
||||||
|
const ny = NET[0][Math.floor(i / 2)];
|
||||||
|
const d = `M 160 ${cy} C 190 ${cy}, 202 ${ny}, ${NET_X[0] - 5} ${ny}`;
|
||||||
|
return (
|
||||||
|
<g key={`feed-${inp.label}`}>
|
||||||
|
{/* The wire is always drawn; only the signal on it moves. Without
|
||||||
|
the base line the dashes read as debris floating in the gap. */}
|
||||||
|
<path d={d} stroke={INK} strokeOpacity="0.13" strokeWidth="1" fill="none" />
|
||||||
|
<path
|
||||||
|
d={d}
|
||||||
|
stroke={RED}
|
||||||
|
strokeOpacity="0.75"
|
||||||
|
strokeWidth="1.3"
|
||||||
|
strokeLinecap="round"
|
||||||
|
fill="none"
|
||||||
|
className="sol-mtc-feed"
|
||||||
|
style={{ animationDelay: `${i * 0.4}s` }}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* ============================================================
|
||||||
|
LAYER 2 — THE DECISION ENGINE
|
||||||
|
============================================================ */}
|
||||||
|
<ellipse
|
||||||
|
cx={CORE.cx}
|
||||||
|
cy={CORE.cy}
|
||||||
|
rx="116"
|
||||||
|
ry="108"
|
||||||
|
fill="url(#solMtcCoreGlow)"
|
||||||
|
className="sol-mtc-glow"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx={CORE.cx}
|
||||||
|
cy={CORE.cy}
|
||||||
|
r="110"
|
||||||
|
fill="none"
|
||||||
|
stroke="rgba(14,14,16,0.13)"
|
||||||
|
strokeWidth="1"
|
||||||
|
strokeDasharray="2 11"
|
||||||
|
className="sol-mtc-ring"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx={CORE.cx}
|
||||||
|
cy={CORE.cy}
|
||||||
|
r="97"
|
||||||
|
fill="none"
|
||||||
|
stroke="rgba(226,53,74,0.3)"
|
||||||
|
strokeWidth="1"
|
||||||
|
strokeDasharray="3 15"
|
||||||
|
className="sol-mtc-ring sol-mtc-ring--b"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Synapses. The full mesh is drawn faint; only the path the engine
|
||||||
|
actually took this cycle is lit and carries signal. */}
|
||||||
|
{NET.slice(0, -1).map((layer, l) =>
|
||||||
|
layer.map((y1, a) =>
|
||||||
|
NET[l + 1].map((y2, b) => {
|
||||||
|
const live = LIVE_SET.has(liveKey(l, a, b));
|
||||||
|
return (
|
||||||
|
<line
|
||||||
|
key={`e-${l}-${a}-${b}`}
|
||||||
|
x1={NET_X[l]}
|
||||||
|
y1={y1}
|
||||||
|
x2={NET_X[l + 1]}
|
||||||
|
y2={y2}
|
||||||
|
stroke={live ? RED : INK}
|
||||||
|
strokeOpacity={live ? 0.55 : 0.1}
|
||||||
|
strokeWidth={live ? 1.4 : 0.8}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{LIVE_PATH.map(([l, a, b]) => (
|
||||||
|
<line
|
||||||
|
key={`live-${l}-${a}-${b}`}
|
||||||
|
x1={NET_X[l]}
|
||||||
|
y1={NET[l][a]}
|
||||||
|
x2={NET_X[l + 1]}
|
||||||
|
y2={NET[l + 1][b]}
|
||||||
|
stroke="#FFFFFF"
|
||||||
|
strokeOpacity="0.85"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
className="sol-mtc-synapse"
|
||||||
|
style={{ animationDelay: `${l * 0.3}s` }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{NET.map((layer, l) =>
|
||||||
|
layer.map((y, i) => {
|
||||||
|
const on = ACTIVE_NODES.has(`${l}-${i}`);
|
||||||
|
return (
|
||||||
|
<g key={`n-${l}-${i}`}>
|
||||||
|
{on && (
|
||||||
|
<>
|
||||||
|
{/* Lit, not neon — a soft halo at half opacity rather than
|
||||||
|
a bloom that turns the node into a lens flare. */}
|
||||||
|
<circle
|
||||||
|
cx={NET_X[l]}
|
||||||
|
cy={y}
|
||||||
|
r="5"
|
||||||
|
fill={RED}
|
||||||
|
fillOpacity="0.45"
|
||||||
|
filter="url(#solMtcNodeGlow)"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx={NET_X[l]}
|
||||||
|
cy={y}
|
||||||
|
r="6"
|
||||||
|
fill="none"
|
||||||
|
stroke={RED}
|
||||||
|
strokeWidth="1.4"
|
||||||
|
className="sol-mtc-fire"
|
||||||
|
style={{ animationDelay: `${l * 0.28}s` }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<circle
|
||||||
|
cx={NET_X[l]}
|
||||||
|
cy={y}
|
||||||
|
r={on ? 4.6 : 3.6}
|
||||||
|
fill={on ? RED : "#FFFFFF"}
|
||||||
|
stroke={on ? RED : "rgba(14,14,16,0.28)"}
|
||||||
|
strokeWidth="1.3"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Decision leaving the core for the outcome column. */}
|
||||||
|
{[
|
||||||
|
{ d: "M 406 168 C 428 168, 432 138, 449 132" },
|
||||||
|
{ d: "M 406 232 C 428 232, 432 252, 449 258" },
|
||||||
|
].map((c, i) => (
|
||||||
|
<g key={`out-${i}`}>
|
||||||
|
<path d={c.d} stroke={RED} strokeOpacity="0.28" strokeWidth="1.3" fill="none" />
|
||||||
|
<path
|
||||||
|
d={c.d}
|
||||||
|
stroke={RED}
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
fill="none"
|
||||||
|
className="sol-mtc-feed"
|
||||||
|
style={{ animationDelay: `${i * 0.5}s` }}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* ============================================================
|
||||||
|
LAYER 3 — THE PROTECTED OUTCOME
|
||||||
|
============================================================ */}
|
||||||
|
<rect
|
||||||
|
x="452"
|
||||||
|
y="94"
|
||||||
|
width="206"
|
||||||
|
height="80"
|
||||||
|
rx="13"
|
||||||
|
fill="rgba(14,14,16,0.02)"
|
||||||
|
stroke="rgba(14,14,16,0.06)"
|
||||||
|
strokeWidth="1"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Rejected route in grey. It has to WANDER, not shadow the red one —
|
||||||
|
two near-identical curves read as a rendering artefact. */}
|
||||||
|
<path
|
||||||
|
d="M 472 148 C 492 167, 524 169, 546 156 C 568 143, 572 112, 598 110 C 616 108, 626 118, 638 124"
|
||||||
|
stroke="rgba(14,14,16,0.24)"
|
||||||
|
strokeWidth="1.8"
|
||||||
|
strokeDasharray="4 5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
id="solMtcAiRoute"
|
||||||
|
d="M 472 148 C 520 143, 580 130, 638 124"
|
||||||
|
stroke="url(#solMtcRouteGrad)"
|
||||||
|
strokeWidth="7"
|
||||||
|
strokeOpacity="0.14"
|
||||||
|
strokeLinecap="round"
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M 472 148 C 520 143, 580 130, 638 124"
|
||||||
|
stroke="url(#solMtcRouteGrad)"
|
||||||
|
strokeWidth="2.6"
|
||||||
|
strokeLinecap="round"
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M 472 148 C 520 143, 580 130, 638 124"
|
||||||
|
stroke="#FFFFFF"
|
||||||
|
strokeOpacity="0.55"
|
||||||
|
strokeWidth="1.2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
fill="none"
|
||||||
|
className="sol-mtc-route-flow"
|
||||||
|
/>
|
||||||
|
<circle cx="472" cy="148" r="4" fill={INK} />
|
||||||
|
<circle cx="638" cy="124" r="5.5" fill="#FFFFFF" stroke={RED} strokeWidth="2" />
|
||||||
|
<circle cx="638" cy="124" r="2" fill={RED} />
|
||||||
|
{!still && (
|
||||||
|
<g>
|
||||||
|
<circle cx="0" cy="0" r="5" fill={RED} fillOpacity="0.4" filter="url(#solMtcNodeGlow)" />
|
||||||
|
<circle cx="0" cy="0" r="3.2" fill={RED} />
|
||||||
|
<animateMotion dur="4.6s" repeatCount="indefinite">
|
||||||
|
<mpath href="#solMtcAiRoute" xlinkHref="#solMtcAiRoute" />
|
||||||
|
</animateMotion>
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Legend below the panel. Labels sat on top of the curves before,
|
||||||
|
which made both unreadable. */}
|
||||||
|
<g>
|
||||||
|
<line
|
||||||
|
x1="454"
|
||||||
|
y1="188"
|
||||||
|
x2="468"
|
||||||
|
y2="188"
|
||||||
|
stroke="rgba(14,14,16,0.3)"
|
||||||
|
strokeWidth="1.8"
|
||||||
|
strokeDasharray="4 4"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x="474"
|
||||||
|
y="191"
|
||||||
|
fill="rgba(14,14,16,0.42)"
|
||||||
|
fontSize="9.5"
|
||||||
|
fontWeight="800"
|
||||||
|
letterSpacing="0.5"
|
||||||
|
fontFamily="Manrope, sans-serif"
|
||||||
|
>
|
||||||
|
Original 58m
|
||||||
|
</text>
|
||||||
|
<line
|
||||||
|
x1="556"
|
||||||
|
y1="188"
|
||||||
|
x2="570"
|
||||||
|
y2="188"
|
||||||
|
stroke={RED}
|
||||||
|
strokeWidth="2.2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x="576"
|
||||||
|
y="191"
|
||||||
|
fill={RED_DEEP}
|
||||||
|
fontSize="9.5"
|
||||||
|
fontWeight="800"
|
||||||
|
letterSpacing="0.5"
|
||||||
|
fontFamily="Manrope, sans-serif"
|
||||||
|
>
|
||||||
|
MileTruth 41m
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{OUTCOMES.map((o, i) => {
|
||||||
|
const cy = 212 + i * 28;
|
||||||
|
return (
|
||||||
|
<g key={o}>
|
||||||
|
<circle
|
||||||
|
cx="462"
|
||||||
|
cy={cy}
|
||||||
|
r="7"
|
||||||
|
fill={RED}
|
||||||
|
fillOpacity="0.18"
|
||||||
|
className="sol-mtc-tick"
|
||||||
|
style={{ animationDelay: `${i * 0.55}s` }}
|
||||||
|
/>
|
||||||
|
<circle cx="462" cy={cy} r="7" fill="#FFFFFF" stroke={RED} strokeWidth="1.4" />
|
||||||
|
<path
|
||||||
|
d={`M 458.8 ${cy} 461.2 ${cy + 2.4} 465.4 ${cy - 2.6}`}
|
||||||
|
stroke={RED_DEEP}
|
||||||
|
strokeWidth="1.6"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x="478"
|
||||||
|
y={cy + 3.6}
|
||||||
|
fill="rgba(14,14,16,0.8)"
|
||||||
|
fontSize="10.5"
|
||||||
|
fontWeight="700"
|
||||||
|
fontFamily="Manrope, sans-serif"
|
||||||
|
>
|
||||||
|
{o}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* ============ KPI STRIP ============ */}
|
||||||
|
<line x1="8" y1="326" x2="672" y2="326" stroke="rgba(14,14,16,0.07)" strokeWidth="1" />
|
||||||
|
<rect x="8" y="326" width="664" height="66" fill="rgba(14,14,16,0.018)" />
|
||||||
|
<line x1="230" y1="342" x2="230" y2="376" stroke="rgba(14,14,16,0.085)" strokeWidth="1" />
|
||||||
|
<line x1="450" y1="342" x2="450" y2="376" stroke="rgba(14,14,16,0.085)" strokeWidth="1" />
|
||||||
|
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
x: 54,
|
||||||
|
v: "41 min",
|
||||||
|
l: "OPTIMISED ETA",
|
||||||
|
ic: (
|
||||||
|
<>
|
||||||
|
<circle cx="13" cy="13" r="6.6" />
|
||||||
|
<path d="M13 8.8V13l3 2" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
x: 275,
|
||||||
|
v: "17 min",
|
||||||
|
l: "TIME SAVED",
|
||||||
|
ic: <path d="M14.2 5.6 8 13.6h4.2L11.4 20.4 17.6 12.4h-4.2Z" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
x: 496,
|
||||||
|
v: "99.2%",
|
||||||
|
l: "SLA PROTECTED",
|
||||||
|
ic: (
|
||||||
|
<>
|
||||||
|
<path d="M13 5.4 19 8v5c0 4.1-3 6.6-6 7.6-3-1-6-3.5-6-7.6V8Z" />
|
||||||
|
<path d="m10.4 12.9 2 2 3.4-3.6" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
].map((k) => (
|
||||||
|
<g key={k.l}>
|
||||||
|
<rect x={k.x} y="337" width="26" height="26" rx="8.5" fill="rgba(226,53,74,0.08)" />
|
||||||
|
<g
|
||||||
|
transform={`translate(${k.x}, 337)`}
|
||||||
|
stroke={RED_DEEP}
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
fill="none"
|
||||||
|
>
|
||||||
|
{k.ic}
|
||||||
|
</g>
|
||||||
|
<text
|
||||||
|
x={k.x + 36}
|
||||||
|
y="355"
|
||||||
|
fill={INK}
|
||||||
|
fontSize="17"
|
||||||
|
fontWeight="800"
|
||||||
|
letterSpacing="-0.3"
|
||||||
|
fontFamily="Manrope, sans-serif"
|
||||||
|
>
|
||||||
|
{k.v}
|
||||||
|
</text>
|
||||||
|
<text
|
||||||
|
x={k.x + 36}
|
||||||
|
y="371"
|
||||||
|
fill="rgba(14,14,16,0.45)"
|
||||||
|
fontSize="9.5"
|
||||||
|
fontWeight="800"
|
||||||
|
letterSpacing="1.15"
|
||||||
|
fontFamily="Manrope, sans-serif"
|
||||||
|
>
|
||||||
|
{k.l}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import gsap from "gsap";
|
import gsap from "gsap";
|
||||||
import { ScrollReveal } from "@/animations/Reveal";
|
import { ScrollReveal } from "@/animations/Reveal";
|
||||||
|
import WingsNeuralEngineAnimation from "./WingsNeuralEngineAnimation";
|
||||||
|
|
||||||
const FLOW_STAGES = [
|
const FLOW_STAGES = [
|
||||||
{
|
{
|
||||||
@@ -722,12 +723,7 @@ export default function WingsMileTruth() {
|
|||||||
<div className="wmt-inner dm-section-container">
|
<div className="wmt-inner dm-section-container">
|
||||||
<ScrollReveal delay={0.05} duration={0.8} yOffset={20} className="wmt-visual-container">
|
<ScrollReveal delay={0.05} duration={0.8} yOffset={20} className="wmt-visual-container">
|
||||||
<TiltCard className="wmt-visual-pane">
|
<TiltCard className="wmt-visual-pane">
|
||||||
<img
|
<WingsNeuralEngineAnimation />
|
||||||
src="/images/miletruth-ai-core.png"
|
|
||||||
alt="MileTruth AI Core Engine Visualization"
|
|
||||||
className="wmt-visual-img"
|
|
||||||
/>
|
|
||||||
<ParticleCanvas />
|
|
||||||
</TiltCard>
|
</TiltCard>
|
||||||
</ScrollReveal>
|
</ScrollReveal>
|
||||||
|
|
||||||
|
|||||||
937
src/components/sections/WingsNeuralEngineAnimation.tsx
Normal file
937
src/components/sections/WingsNeuralEngineAnimation.tsx
Normal file
@@ -0,0 +1,937 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
WingsNeuralEngineAnimation — the live visual beside the MileTruth™ AI copy
|
||||||
|
on /doormile-wings. Wings-page only; the /solutions and home variants
|
||||||
|
(SolutionsMileTruthAnimation, AiCoreAnimation) are separate files.
|
||||||
|
|
||||||
|
This is a *simulation*, not a timeline: every packet, cadence and readout
|
||||||
|
is randomised per event, so the panel never repeats a frame and reads as
|
||||||
|
something actually running. Six named telemetry sources stream packets
|
||||||
|
into a core, the core breathes with its own throughput, and every few
|
||||||
|
seconds it fires a recommendation out to the insight stack.
|
||||||
|
|
||||||
|
Canvas paints the field / ring / core / bursts; the HUD is HTML so the
|
||||||
|
numbers stay crisp. One rAF loop drives both — HUD text is written through
|
||||||
|
refs, never through React state, so there is no re-render per frame.
|
||||||
|
|
||||||
|
Lifecycle follows the site canvas pattern (see NeuralField, WorkflowScene):
|
||||||
|
DPR-aware, ResizeObserver-sized, rAF paused off-screen and on hidden tabs,
|
||||||
|
prefers-reduced-motion paints one static frame and stops.
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
/** Telemetry feeds on the ingest ring, in ring order from 12 o'clock running
|
||||||
|
* clockwise. Every transport value here is a fixed constant: `bend` is that
|
||||||
|
* feed's path curvature, `speed` its transit rate, `dwell` the pause after
|
||||||
|
* absorption before it sends again. The six packets differ because these
|
||||||
|
* numbers differ — there is no spawner and no randomness in the transport.
|
||||||
|
* Labels are ordered so the long ones land on the sides, where there is room
|
||||||
|
* to set them outside the ring. */
|
||||||
|
const SOURCES = [
|
||||||
|
{ label: "Orders", bend: 0.26, speed: 0.4, dwell: 900 },
|
||||||
|
{ label: "Fleet Telemetry", bend: -0.19, speed: 0.52, dwell: 520 },
|
||||||
|
{ label: "Routes", bend: 0.34, speed: 0.35, dwell: 1150 },
|
||||||
|
{ label: "Traffic", bend: -0.3, speed: 0.47, dwell: 700 },
|
||||||
|
{ label: "Weather", bend: 0.14, speed: 0.3, dwell: 1500 },
|
||||||
|
{ label: "POD Scans", bend: -0.38, speed: 0.43, dwell: 1050 },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Ring radius as a fraction of the short edge. Shared by the canvas and the
|
||||||
|
* HTML labels so the two layers stay locked together. */
|
||||||
|
const RING = 0.28;
|
||||||
|
|
||||||
|
/** Recommendation pool. `fn` builds the value half fresh each time so the
|
||||||
|
* same insight never reads identically twice. */
|
||||||
|
const INSIGHTS: Array<{ head: string; fn: () => string; good?: boolean }> = [
|
||||||
|
{ head: "Reroute DEL → JAI", fn: () => `−${11 + Math.floor(Math.random() * 14)} min`, good: true },
|
||||||
|
{ head: "SLA risk cleared", fn: () => `BLR-${10 + Math.floor(Math.random() * 80)}`, good: true },
|
||||||
|
{ head: "Load rebalanced", fn: () => `Hub ${1 + Math.floor(Math.random() * 4)} → Hub ${5 + Math.floor(Math.random() * 3)}` },
|
||||||
|
{ head: "Demand spike", fn: () => `Chennai +${9 + Math.floor(Math.random() * 22)}%` },
|
||||||
|
{ head: "Idle dwell trimmed", fn: () => `−${4 + Math.floor(Math.random() * 9)} min/stop`, good: true },
|
||||||
|
{ head: "Capacity shifted", fn: () => `${2 + Math.floor(Math.random() * 6)} vehicles` },
|
||||||
|
{ head: "Anomaly flagged", fn: () => `Lane HYD-${100 + Math.floor(Math.random() * 90)}` },
|
||||||
|
{ head: "Cold-chain hold", fn: () => `${2 + Math.floor(Math.random() * 5)} shipments` },
|
||||||
|
];
|
||||||
|
|
||||||
|
type Insight = { id: number; head: string; value: string; good: boolean };
|
||||||
|
|
||||||
|
const rand = (a: number, b: number) => a + Math.random() * (b - a);
|
||||||
|
|
||||||
|
export default function WingsNeuralEngineAnimation({ className = "" }: { className?: string }) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const stageRef = useRef<HTMLDivElement>(null);
|
||||||
|
const countRef = useRef<HTMLElement>(null);
|
||||||
|
const rateRef = useRef<HTMLSpanElement>(null);
|
||||||
|
const confRef = useRef<HTMLSpanElement>(null);
|
||||||
|
const slaRef = useRef<HTMLSpanElement>(null);
|
||||||
|
const sparkRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
/** Set by the sim so the effect can push insight cards into React state. */
|
||||||
|
const pushInsightRef = useRef<() => void>(() => {});
|
||||||
|
|
||||||
|
const [insights, setInsights] = useState<Insight[]>([]);
|
||||||
|
|
||||||
|
// Insight cards are the one piece of React state here — they change a few
|
||||||
|
// times a minute, not 60 times a second.
|
||||||
|
useEffect(() => {
|
||||||
|
let nextId = 1;
|
||||||
|
pushInsightRef.current = () => {
|
||||||
|
const pick = INSIGHTS[Math.floor(Math.random() * INSIGHTS.length)];
|
||||||
|
const card: Insight = {
|
||||||
|
id: nextId++,
|
||||||
|
head: pick.head,
|
||||||
|
value: pick.fn(),
|
||||||
|
good: !!pick.good,
|
||||||
|
};
|
||||||
|
setInsights((prev) => [card, ...prev].slice(0, 3));
|
||||||
|
window.setTimeout(() => {
|
||||||
|
setInsights((prev) => prev.filter((c) => c.id !== card.id));
|
||||||
|
}, 4600);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const stage = stageRef.current;
|
||||||
|
if (!canvas || !stage) return;
|
||||||
|
const ctx = canvas.getContext("2d", { alpha: true });
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
|
||||||
|
let w = 0;
|
||||||
|
let h = 0;
|
||||||
|
/** Normalised unit — every radius below is a fraction of the short edge,
|
||||||
|
* so the scene composes identically at 580px and at 300px. */
|
||||||
|
let u = 0;
|
||||||
|
let cx = 0;
|
||||||
|
let cy = 0;
|
||||||
|
let raf = 0;
|
||||||
|
let running = false;
|
||||||
|
let last = 0;
|
||||||
|
|
||||||
|
/* ---- transport -------------------------------------------------------
|
||||||
|
One slot per feed. `pt[i]` is that feed's packet progress along its
|
||||||
|
path (0..1), or -1 while the feed is waiting. A slot cannot hold two
|
||||||
|
packets, so "one active packet per source, no duplicates" is a property
|
||||||
|
of the data structure rather than a rule a spawner has to respect. */
|
||||||
|
const pt: number[] = SOURCES.map(() => -1);
|
||||||
|
/** Countdown to the next send. Staggered by a fixed offset, not random. */
|
||||||
|
const wait: number[] = SOURCES.map((_, i) => 200 + i * 260);
|
||||||
|
|
||||||
|
/** Core pulse: 1 on absorption, back to 0 in ~300ms. */
|
||||||
|
let pulse = 0;
|
||||||
|
/** The single absorption ripple: -1 idle, else 0..1. One at a time. */
|
||||||
|
let ripple = -1;
|
||||||
|
|
||||||
|
/* Three segmented rings. Each drifts very slowly at its own rate, and on
|
||||||
|
absorption exactly one ring — taken in turn — is stepped a further 15°,
|
||||||
|
eased in rather than snapped. */
|
||||||
|
const RINGS = [
|
||||||
|
{ r: 1.2, seg: 3, gap: 0.66, a: 0.3, w: 1.1, sp: 0.000055 },
|
||||||
|
{ r: 1.4, seg: 4, gap: 0.82, a: 0.18, w: 1, sp: -0.000034 },
|
||||||
|
{ r: 1.6, seg: 2, gap: 1.0, a: 0.12, w: 1, sp: 0.000021 },
|
||||||
|
];
|
||||||
|
const ringPhase = [0.4, 2.1, 4.3];
|
||||||
|
const ringStep = [0, 0, 0];
|
||||||
|
const ringStepTo = [0, 0, 0];
|
||||||
|
let nextRing = 0;
|
||||||
|
|
||||||
|
/* ---- HUD readouts (data, not motion) ---- */
|
||||||
|
let analysed = 1284392;
|
||||||
|
let eventsPerSec = 1180;
|
||||||
|
let confidence = 97.4;
|
||||||
|
let sla = 99.2;
|
||||||
|
let hudDue = 0;
|
||||||
|
let nextInsight = 2600;
|
||||||
|
|
||||||
|
// The DoorMile symbol — icon only, the same asset the solutions engine
|
||||||
|
// core uses. Drawn on canvas so packets can vanish behind it.
|
||||||
|
const mark = new window.Image();
|
||||||
|
let markReady = false;
|
||||||
|
mark.onload = () => {
|
||||||
|
markReady = true;
|
||||||
|
if (reduce) draw();
|
||||||
|
};
|
||||||
|
mark.src = "/images/doormile-mark.png";
|
||||||
|
// Seeded with noise so the throughput trace never opens on a flat plateau.
|
||||||
|
const spark: number[] = Array.from({ length: 28 }, () => rand(0.3, 0.7));
|
||||||
|
|
||||||
|
/* ---- geometry ---- */
|
||||||
|
const ringR = () => u * RING;
|
||||||
|
const coreR = () => u * 0.052;
|
||||||
|
|
||||||
|
function srcPos(i: number) {
|
||||||
|
// Ring starts at the top and runs clockwise; the six labels in the HUD
|
||||||
|
// use the same formula so canvas and DOM stay locked together.
|
||||||
|
const a = -Math.PI / 2 + (i / SOURCES.length) * Math.PI * 2;
|
||||||
|
return { x: cx + Math.cos(a) * ringR(), y: cy + Math.sin(a) * ringR(), a };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fixed quadratic bezier from feed `i` to the core. The control point is
|
||||||
|
* derived from that feed's constant `bend`, so the route never varies. */
|
||||||
|
function packetPos(i: number, t: number) {
|
||||||
|
const s = srcPos(i);
|
||||||
|
const mx = (s.x + cx) / 2;
|
||||||
|
const my = (s.y + cy) / 2;
|
||||||
|
const nx = -(cy - s.y);
|
||||||
|
const ny = cx - s.x;
|
||||||
|
const nl = Math.hypot(nx, ny) || 1;
|
||||||
|
const k = SOURCES[i].bend * u * 0.34;
|
||||||
|
const bx = mx + (nx / nl) * k;
|
||||||
|
const by = my + (ny / nl) * k;
|
||||||
|
const it = 1 - t;
|
||||||
|
return {
|
||||||
|
x: it * it * s.x + 2 * it * t * bx + t * t * cx,
|
||||||
|
y: it * it * s.y + 2 * it * t * by + t * t * cy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- painting ------------------------------------------------------ */
|
||||||
|
|
||||||
|
/** Background: the dark grid and one fixed pool of light. Both static. */
|
||||||
|
function drawField() {
|
||||||
|
const gap = Math.max(18, u * 0.052);
|
||||||
|
ctx!.fillStyle = "rgba(255,255,255,0.045)";
|
||||||
|
for (let x = gap / 2; x < w; x += gap) {
|
||||||
|
for (let y = gap / 2; y < h; y += gap) {
|
||||||
|
ctx!.beginPath();
|
||||||
|
ctx!.arc(x, y, 1, 0, Math.PI * 2);
|
||||||
|
ctx!.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const glow = ctx!.createRadialGradient(cx, cy, 0, cx, cy, u * 0.5);
|
||||||
|
glow.addColorStop(0, "rgba(226,53,74,0.09)");
|
||||||
|
glow.addColorStop(0.5, "rgba(192,18,39,0.035)");
|
||||||
|
glow.addColorStop(1, "rgba(226,53,74,0)");
|
||||||
|
ctx!.fillStyle = glow;
|
||||||
|
ctx!.fillRect(0, 0, w, h);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Input nodes and their route guide. Entirely static: a feed's activity
|
||||||
|
* is told by its packet, so the node itself never needs to flash. */
|
||||||
|
function drawRing() {
|
||||||
|
ctx!.strokeStyle = "rgba(255,255,255,0.07)";
|
||||||
|
ctx!.lineWidth = 1;
|
||||||
|
ctx!.setLineDash([3, 7]);
|
||||||
|
ctx!.beginPath();
|
||||||
|
ctx!.arc(cx, cy, ringR(), 0, Math.PI * 2);
|
||||||
|
ctx!.stroke();
|
||||||
|
ctx!.setLineDash([]);
|
||||||
|
|
||||||
|
for (let i = 0; i < SOURCES.length; i++) {
|
||||||
|
const s = srcPos(i);
|
||||||
|
ctx!.beginPath();
|
||||||
|
ctx!.arc(s.x, s.y, u * 0.019, 0, Math.PI * 2);
|
||||||
|
ctx!.strokeStyle = "rgba(255,59,59,0.26)";
|
||||||
|
ctx!.lineWidth = 1;
|
||||||
|
ctx!.stroke();
|
||||||
|
|
||||||
|
ctx!.beginPath();
|
||||||
|
ctx!.arc(s.x, s.y, Math.max(2, u * 0.0062), 0, Math.PI * 2);
|
||||||
|
ctx!.fillStyle = "rgba(255,130,130,0.7)";
|
||||||
|
ctx!.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The six data packets. Nothing else on this canvas travels. */
|
||||||
|
function drawPackets() {
|
||||||
|
for (let i = 0; i < SOURCES.length; i++) {
|
||||||
|
const t = pt[i];
|
||||||
|
if (t < 0) continue;
|
||||||
|
const head = packetPos(i, t);
|
||||||
|
// A short trail behind the head: a bare dot reads as a floating
|
||||||
|
// particle, a trail reads as something traveling a route.
|
||||||
|
const tail = packetPos(i, Math.max(0, t - 0.075));
|
||||||
|
const a = Math.min(1, t * 5) * (0.5 + t * 0.5) * Math.min(1, (1 - t) / 0.07);
|
||||||
|
const tint = `255,${Math.round(130 + t * 100)},${Math.round(135 + t * 100)}`;
|
||||||
|
|
||||||
|
const tg = ctx!.createLinearGradient(tail.x, tail.y, head.x, head.y);
|
||||||
|
tg.addColorStop(0, `rgba(${tint},0)`);
|
||||||
|
tg.addColorStop(1, `rgba(${tint},${(a * 0.5).toFixed(3)})`);
|
||||||
|
ctx!.beginPath();
|
||||||
|
ctx!.moveTo(tail.x, tail.y);
|
||||||
|
ctx!.lineTo(head.x, head.y);
|
||||||
|
ctx!.strokeStyle = tg;
|
||||||
|
ctx!.lineWidth = 1.4;
|
||||||
|
ctx!.lineCap = "round";
|
||||||
|
ctx!.stroke();
|
||||||
|
|
||||||
|
ctx!.save();
|
||||||
|
ctx!.shadowColor = "rgba(255,59,59,0.8)";
|
||||||
|
ctx!.shadowBlur = 5;
|
||||||
|
ctx!.beginPath();
|
||||||
|
ctx!.arc(head.x, head.y, 2.3, 0, Math.PI * 2);
|
||||||
|
ctx!.fillStyle = `rgba(${tint},${a.toFixed(3)})`;
|
||||||
|
ctx!.fill();
|
||||||
|
ctx!.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- the core: a glass processor with the brand symbol at its heart.
|
||||||
|
Painted in two passes so packets can fly over the glass and disappear
|
||||||
|
*into* the symbol rather than stopping at its rim. ---- */
|
||||||
|
|
||||||
|
/** Radius of the glass housing. */
|
||||||
|
const glassR = () => coreR() * 2.15;
|
||||||
|
|
||||||
|
function drawCoreBack() {
|
||||||
|
const R = glassR();
|
||||||
|
ctx!.save();
|
||||||
|
ctx!.translate(cx, cy);
|
||||||
|
|
||||||
|
// Seat the assembly into the field — gradient only, no drawn rim here.
|
||||||
|
const pg = ctx!.createRadialGradient(0, 0, R * 0.7, 0, 0, R * 2.4);
|
||||||
|
pg.addColorStop(0, "rgba(7,7,16,0)");
|
||||||
|
pg.addColorStop(0.45, "rgba(7,7,16,0.5)");
|
||||||
|
pg.addColorStop(1, "rgba(7,7,16,0)");
|
||||||
|
ctx!.fillStyle = pg;
|
||||||
|
ctx!.beginPath();
|
||||||
|
ctx!.arc(0, 0, R * 2.4, 0, Math.PI * 2);
|
||||||
|
ctx!.fill();
|
||||||
|
|
||||||
|
// The absorption ripple: one, small, inside the ring stack.
|
||||||
|
if (ripple >= 0) {
|
||||||
|
ctx!.beginPath();
|
||||||
|
ctx!.arc(0, 0, R * (1.02 + ripple * 0.55), 0, Math.PI * 2);
|
||||||
|
ctx!.strokeStyle = `rgba(255,140,140,${((1 - ripple) * 0.3).toFixed(3)})`;
|
||||||
|
ctx!.lineWidth = 1;
|
||||||
|
ctx!.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Three segmented rings. Nothing rides them and nothing highlights
|
||||||
|
// them — the rotation alone is the computation.
|
||||||
|
for (let i = 0; i < RINGS.length; i++) {
|
||||||
|
const g = RINGS[i];
|
||||||
|
const span = (Math.PI * 2) / g.seg;
|
||||||
|
const base = ringPhase[i] + ringStep[i];
|
||||||
|
for (let j = 0; j < g.seg; j++) {
|
||||||
|
const s0 = base + j * span;
|
||||||
|
ctx!.beginPath();
|
||||||
|
ctx!.arc(0, 0, R * g.r, s0, s0 + span - g.gap);
|
||||||
|
ctx!.strokeStyle = `rgba(255,90,90,${g.a})`;
|
||||||
|
ctx!.lineWidth = g.w;
|
||||||
|
ctx!.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- glass housing --- */
|
||||||
|
const body = ctx!.createLinearGradient(0, -R, 0, R);
|
||||||
|
body.addColorStop(0, "rgba(255,255,255,0.075)");
|
||||||
|
body.addColorStop(0.5, "rgba(255,255,255,0.022)");
|
||||||
|
body.addColorStop(1, "rgba(255,70,70,0.05)");
|
||||||
|
ctx!.beginPath();
|
||||||
|
ctx!.arc(0, 0, R, 0, Math.PI * 2);
|
||||||
|
ctx!.fillStyle = body;
|
||||||
|
ctx!.fill();
|
||||||
|
|
||||||
|
// Light held inside the glass, lifting only with the core pulse.
|
||||||
|
const inner = ctx!.createRadialGradient(0, 0, 0, 0, 0, R);
|
||||||
|
inner.addColorStop(0, `rgba(255,70,70,${(0.12 + pulse * 0.3).toFixed(3)})`);
|
||||||
|
inner.addColorStop(0.6, "rgba(226,53,74,0.05)");
|
||||||
|
inner.addColorStop(1, "rgba(226,53,74,0)");
|
||||||
|
ctx!.fillStyle = inner;
|
||||||
|
ctx!.fill();
|
||||||
|
|
||||||
|
// Rim: bright where the light falls, warm on the far side.
|
||||||
|
const rim = ctx!.createLinearGradient(-R * 0.75, -R * 0.75, R * 0.75, R * 0.75);
|
||||||
|
rim.addColorStop(0, "rgba(255,255,255,0.55)");
|
||||||
|
rim.addColorStop(0.4, "rgba(255,255,255,0.1)");
|
||||||
|
rim.addColorStop(1, `rgba(255,90,90,${(0.28 + pulse * 0.3).toFixed(3)})`);
|
||||||
|
ctx!.beginPath();
|
||||||
|
ctx!.arc(0, 0, R, 0, Math.PI * 2);
|
||||||
|
ctx!.strokeStyle = rim;
|
||||||
|
ctx!.lineWidth = 1.2;
|
||||||
|
ctx!.stroke();
|
||||||
|
|
||||||
|
// Specular sweep on the upper-left shoulder — the tell that it is glass.
|
||||||
|
// Faded at both ends so it reads as light on a curve, not a drawn arc.
|
||||||
|
const spec = ctx!.createLinearGradient(-R, -R * 0.4, 0, -R);
|
||||||
|
spec.addColorStop(0, "rgba(255,255,255,0)");
|
||||||
|
spec.addColorStop(0.5, "rgba(255,255,255,0.42)");
|
||||||
|
spec.addColorStop(1, "rgba(255,255,255,0)");
|
||||||
|
ctx!.beginPath();
|
||||||
|
ctx!.arc(0, 0, R - 2.2, Math.PI * 1.05, Math.PI * 1.55);
|
||||||
|
ctx!.strokeStyle = spec;
|
||||||
|
ctx!.lineWidth = 1.8;
|
||||||
|
ctx!.lineCap = "round";
|
||||||
|
ctx!.stroke();
|
||||||
|
|
||||||
|
ctx!.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawCoreFront() {
|
||||||
|
if (!markReady) return;
|
||||||
|
const R = glassR();
|
||||||
|
ctx!.save();
|
||||||
|
ctx!.translate(cx, cy);
|
||||||
|
|
||||||
|
// The symbol itself never moves: only its glow answers an absorption.
|
||||||
|
const mw = R * 1.02;
|
||||||
|
const mh = mw * (mark.height / mark.width);
|
||||||
|
|
||||||
|
ctx!.shadowColor = `rgba(255,90,90,${(0.45 + pulse * 0.45).toFixed(3)})`;
|
||||||
|
ctx!.shadowBlur = R * (0.15 + pulse * 0.38);
|
||||||
|
ctx!.globalAlpha = 0.97;
|
||||||
|
ctx!.drawImage(mark, -mw / 2, -mh / 2, mw, mh);
|
||||||
|
|
||||||
|
if (pulse > 0.02) {
|
||||||
|
ctx!.globalAlpha = Math.min(0.5, pulse * 0.5);
|
||||||
|
ctx!.shadowColor = "rgba(255,255,255,0.95)";
|
||||||
|
ctx!.shadowBlur = R * (0.2 + pulse * 0.45);
|
||||||
|
ctx!.drawImage(mark, -mw / 2, -mh / 2, mw, mh);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx!.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawSpark() {
|
||||||
|
const sc = sparkRef.current;
|
||||||
|
if (!sc) return;
|
||||||
|
const sctx = sc.getContext("2d");
|
||||||
|
if (!sctx) return;
|
||||||
|
const dpr = Math.min(2, window.devicePixelRatio || 1);
|
||||||
|
const rect = sc.getBoundingClientRect();
|
||||||
|
if (!rect.width || !rect.height) return;
|
||||||
|
if (sc.width !== Math.round(rect.width * dpr)) {
|
||||||
|
sc.width = Math.round(rect.width * dpr);
|
||||||
|
sc.height = Math.round(rect.height * dpr);
|
||||||
|
}
|
||||||
|
sctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
sctx.clearRect(0, 0, rect.width, rect.height);
|
||||||
|
|
||||||
|
// Normalise against the buffer's own range so the trace always uses the
|
||||||
|
// full height — a fixed scale reads as a dead flat line.
|
||||||
|
let lo = Infinity;
|
||||||
|
let hi = -Infinity;
|
||||||
|
for (const v of spark) {
|
||||||
|
if (v < lo) lo = v;
|
||||||
|
if (v > hi) hi = v;
|
||||||
|
}
|
||||||
|
const span = Math.max(0.06, hi - lo);
|
||||||
|
const px = (i: number) => (i / (spark.length - 1)) * rect.width;
|
||||||
|
const py = (v: number) => rect.height - 2 - ((v - lo) / span) * (rect.height - 4);
|
||||||
|
|
||||||
|
sctx.beginPath();
|
||||||
|
sctx.moveTo(px(0), py(spark[0]));
|
||||||
|
for (let i = 1; i < spark.length; i++) sctx.lineTo(px(i), py(spark[i]));
|
||||||
|
|
||||||
|
// Fill under the trace, then re-stroke the line on top.
|
||||||
|
sctx.save();
|
||||||
|
sctx.lineTo(rect.width, rect.height);
|
||||||
|
sctx.lineTo(0, rect.height);
|
||||||
|
sctx.closePath();
|
||||||
|
const fg = sctx.createLinearGradient(0, 0, 0, rect.height);
|
||||||
|
fg.addColorStop(0, "rgba(255,59,59,0.28)");
|
||||||
|
fg.addColorStop(1, "rgba(255,59,59,0)");
|
||||||
|
sctx.fillStyle = fg;
|
||||||
|
sctx.fill();
|
||||||
|
sctx.restore();
|
||||||
|
|
||||||
|
sctx.beginPath();
|
||||||
|
sctx.moveTo(px(0), py(spark[0]));
|
||||||
|
for (let i = 1; i < spark.length; i++) sctx.lineTo(px(i), py(spark[i]));
|
||||||
|
sctx.strokeStyle = "rgba(255,90,90,0.95)";
|
||||||
|
sctx.lineWidth = 1.2;
|
||||||
|
sctx.lineJoin = "round";
|
||||||
|
sctx.stroke();
|
||||||
|
|
||||||
|
// Live head dot.
|
||||||
|
sctx.beginPath();
|
||||||
|
sctx.arc(px(spark.length - 1), py(spark[spark.length - 1]), 1.8, 0, Math.PI * 2);
|
||||||
|
sctx.fillStyle = "#ff6b6b";
|
||||||
|
sctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
function draw() {
|
||||||
|
ctx!.clearRect(0, 0, w, h);
|
||||||
|
drawField();
|
||||||
|
drawRing();
|
||||||
|
drawCoreBack();
|
||||||
|
drawPackets();
|
||||||
|
drawCoreFront();
|
||||||
|
drawSpark();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- integration ---------------------------------------------------- */
|
||||||
|
|
||||||
|
/** A packet reached the centre: one ripple, one ring stepped 15°, the
|
||||||
|
* logo glow lifts, and the feed starts its fixed wait. Nothing else. */
|
||||||
|
function absorb(i: number) {
|
||||||
|
pulse = 1;
|
||||||
|
ripple = 0;
|
||||||
|
ringStepTo[nextRing % RINGS.length] += Math.PI / 12;
|
||||||
|
nextRing++;
|
||||||
|
pt[i] = -1;
|
||||||
|
wait[i] = SOURCES[i].dwell;
|
||||||
|
// The counter measures exactly this: packets the core has taken in.
|
||||||
|
analysed += 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
function step(dt: number) {
|
||||||
|
const k = Math.min(3, dt / 16.67);
|
||||||
|
|
||||||
|
// Transport: each feed is either carrying or waiting. Never both.
|
||||||
|
for (let i = 0; i < SOURCES.length; i++) {
|
||||||
|
if (pt[i] < 0) {
|
||||||
|
wait[i] -= dt;
|
||||||
|
if (wait[i] <= 0) pt[i] = 0;
|
||||||
|
} else {
|
||||||
|
// Accelerates as it falls into the core.
|
||||||
|
pt[i] += ((SOURCES[i].speed * (0.55 + pt[i] * 0.9)) / 60) * k;
|
||||||
|
if (pt[i] >= 1) absorb(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Core pulse settles in ~300ms.
|
||||||
|
pulse = Math.max(0, pulse - 0.055 * k);
|
||||||
|
|
||||||
|
if (ripple >= 0) {
|
||||||
|
ripple += 0.04 * k;
|
||||||
|
if (ripple >= 1) ripple = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rings: constant slow drift, plus the eased 15° steps.
|
||||||
|
for (let i = 0; i < RINGS.length; i++) {
|
||||||
|
ringPhase[i] += dt * RINGS[i].sp;
|
||||||
|
ringStep[i] += (ringStepTo[i] - ringStep[i]) * Math.min(1, dt / 380);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- HUD: readouts and recommendation cards. No canvas motion. ---- */
|
||||||
|
nextInsight -= dt;
|
||||||
|
if (nextInsight <= 0) {
|
||||||
|
nextInsight = rand(2600, 4200);
|
||||||
|
pushInsightRef.current();
|
||||||
|
confidence = Math.min(99.1, Math.max(95.6, confidence + rand(-0.35, 0.4)));
|
||||||
|
sla = Math.min(99.8, Math.max(98.4, sla + rand(-0.12, 0.14)));
|
||||||
|
}
|
||||||
|
|
||||||
|
hudDue -= dt;
|
||||||
|
if (hudDue <= 0) {
|
||||||
|
hudDue = 140;
|
||||||
|
eventsPerSec = Math.min(2400, Math.max(620, eventsPerSec + rand(-70, 72)));
|
||||||
|
spark.push(Math.min(1, Math.max(0, (eventsPerSec - 600) / 1800)));
|
||||||
|
spark.shift();
|
||||||
|
if (countRef.current) countRef.current.textContent = analysed.toLocaleString("en-US");
|
||||||
|
if (rateRef.current) rateRef.current.textContent = (eventsPerSec / 1000).toFixed(2) + "k";
|
||||||
|
if (confRef.current) confRef.current.textContent = confidence.toFixed(1) + "%";
|
||||||
|
if (slaRef.current) slaRef.current.textContent = sla.toFixed(1) + "%";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function frame(time: number) {
|
||||||
|
const dt = last ? Math.min(64, time - last) : 16.67;
|
||||||
|
last = time;
|
||||||
|
step(dt);
|
||||||
|
draw();
|
||||||
|
raf = requestAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
if (running || reduce) return;
|
||||||
|
running = true;
|
||||||
|
last = 0;
|
||||||
|
raf = requestAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
function stop() {
|
||||||
|
running = false;
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resize() {
|
||||||
|
const rect = stage!.getBoundingClientRect();
|
||||||
|
if (!rect.width || !rect.height) return;
|
||||||
|
const dpr = Math.min(2, window.devicePixelRatio || 1);
|
||||||
|
w = rect.width;
|
||||||
|
h = rect.height;
|
||||||
|
u = Math.min(w, h);
|
||||||
|
cx = w / 2;
|
||||||
|
cy = h / 2;
|
||||||
|
canvas!.width = Math.round(w * dpr);
|
||||||
|
canvas!.height = Math.round(h * dpr);
|
||||||
|
canvas!.style.width = w + "px";
|
||||||
|
canvas!.style.height = h + "px";
|
||||||
|
ctx!.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
if (reduce) draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
const ro = new ResizeObserver(resize);
|
||||||
|
ro.observe(stage);
|
||||||
|
resize();
|
||||||
|
|
||||||
|
if (reduce) {
|
||||||
|
// One composed static frame: every feed frozen mid-transit, steady
|
||||||
|
// numbers. Each packet sits at a different point on its own route.
|
||||||
|
for (let i = 0; i < SOURCES.length; i++) {
|
||||||
|
pt[i] = 0.28 + i * 0.1;
|
||||||
|
}
|
||||||
|
draw();
|
||||||
|
if (countRef.current) countRef.current.textContent = analysed.toLocaleString("en-US");
|
||||||
|
if (rateRef.current) rateRef.current.textContent = "1.18k";
|
||||||
|
if (confRef.current) confRef.current.textContent = confidence.toFixed(1) + "%";
|
||||||
|
if (slaRef.current) slaRef.current.textContent = sla.toFixed(1) + "%";
|
||||||
|
return () => {
|
||||||
|
ro.disconnect();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let visible = true;
|
||||||
|
const sync = () => {
|
||||||
|
if (visible && !document.hidden) start();
|
||||||
|
else stop();
|
||||||
|
};
|
||||||
|
const io = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
visible = entries.some((e) => e.isIntersecting);
|
||||||
|
sync();
|
||||||
|
},
|
||||||
|
{ rootMargin: "120px 0px" },
|
||||||
|
);
|
||||||
|
io.observe(canvas);
|
||||||
|
document.addEventListener("visibilitychange", sync);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
stop();
|
||||||
|
ro.disconnect();
|
||||||
|
io.disconnect();
|
||||||
|
document.removeEventListener("visibilitychange", sync);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`wne-wrap ${className}`}
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
minHeight: "100%",
|
||||||
|
background: "#070710",
|
||||||
|
borderRadius: "24px",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<style jsx>{`
|
||||||
|
.wne-stage {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wne-canvas {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wne-hud {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- source labels, positioned to match the canvas ring ---- */
|
||||||
|
.wne-src {
|
||||||
|
position: absolute;
|
||||||
|
font-size: clamp(7.5px, 1.5cqw, 9.5px);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.7px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: rgba(255, 255, 255, 0.55);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- live pill + counter ---- */
|
||||||
|
.wne-live {
|
||||||
|
position: absolute;
|
||||||
|
top: 5.5%;
|
||||||
|
left: 5.5%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(7, 7, 16, 0.72);
|
||||||
|
border: 1px solid rgba(255, 59, 59, 0.28);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wne-live-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #ff3b3b;
|
||||||
|
box-shadow: 0 0 10px #ff3b3b;
|
||||||
|
animation: wne-blink 1.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes wne-blink {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.35;
|
||||||
|
transform: scale(0.75);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wne-live-text {
|
||||||
|
font-size: clamp(8px, 1.5cqw, 10px);
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 1.4px;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wne-count {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(5.5% + 40px);
|
||||||
|
left: 5.5%;
|
||||||
|
font-size: clamp(8px, 1.5cqw, 10.5px);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
color: rgba(255, 255, 255, 0.42);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wne-count b {
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
font-weight: 800;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- insight stack ---- */
|
||||||
|
.wne-insights {
|
||||||
|
position: absolute;
|
||||||
|
top: 5.5%;
|
||||||
|
right: 5.5%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: flex-end;
|
||||||
|
max-width: 62%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wne-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 7px 11px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(15, 8, 12, 0.82);
|
||||||
|
border: 1px solid rgba(255, 59, 59, 0.22);
|
||||||
|
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.5);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
animation: wne-card-in 0.45s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes wne-card-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(14px) scale(0.96);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wne-card-head {
|
||||||
|
font-size: clamp(8px, 1.5cqw, 10.5px);
|
||||||
|
font-weight: 700;
|
||||||
|
color: rgba(255, 255, 255, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wne-card-value {
|
||||||
|
font-size: clamp(8px, 1.5cqw, 10.5px);
|
||||||
|
font-weight: 800;
|
||||||
|
color: #ff3b3b;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wne-card-value.good {
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- bottom metric strip ---- */
|
||||||
|
.wne-metrics {
|
||||||
|
position: absolute;
|
||||||
|
left: 5.5%;
|
||||||
|
right: 5.5%;
|
||||||
|
bottom: 5.5%;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(7, 7, 16, 0.68);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wne-metric {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wne-metric-label {
|
||||||
|
font-size: clamp(7px, 1.35cqw, 9px);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.9px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: rgba(255, 255, 255, 0.38);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wne-metric-value {
|
||||||
|
font-size: clamp(11px, 2.3cqw, 15px);
|
||||||
|
font-weight: 800;
|
||||||
|
color: #ffffff;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wne-metric-value small {
|
||||||
|
font-size: 0.62em;
|
||||||
|
font-weight: 700;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
margin-left: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wne-spark {
|
||||||
|
width: clamp(46px, 11cqw, 76px);
|
||||||
|
height: 20px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wne-sr {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0 0 0 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.wne-src {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Narrow panel: the ring labels and the sparkline are the first things
|
||||||
|
to go, so the three readouts keep room to breathe. */
|
||||||
|
@container (max-width: 430px) {
|
||||||
|
.wne-src {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.wne-spark {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.wne-metrics {
|
||||||
|
padding: 9px 11px;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.wne-metric-label {
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
}
|
||||||
|
.wne-insights {
|
||||||
|
max-width: 74%;
|
||||||
|
}
|
||||||
|
.wne-live {
|
||||||
|
padding: 5px 9px;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.wne-live-text {
|
||||||
|
letter-spacing: 0.9px;
|
||||||
|
}
|
||||||
|
.wne-count {
|
||||||
|
top: calc(5.5% + 32px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
<div ref={stageRef} className="wne-stage" style={{ containerType: "size" }}>
|
||||||
|
<canvas ref={canvasRef} className="wne-canvas" aria-hidden="true" />
|
||||||
|
|
||||||
|
<div className="wne-hud" aria-hidden="true">
|
||||||
|
{SOURCES.map((s, i) => {
|
||||||
|
// Same polar formula the canvas uses. Side nodes set their label
|
||||||
|
// outward from an inner edge (so long labels grow away from the
|
||||||
|
// card wall); top/bottom nodes hang theirs above, which also keeps
|
||||||
|
// the 6 o'clock label clear of the metric strip.
|
||||||
|
const a = -Math.PI / 2 + (i / SOURCES.length) * Math.PI * 2;
|
||||||
|
const x = 50 + Math.cos(a) * RING * 100;
|
||||||
|
const y = 50 + Math.sin(a) * RING * 100;
|
||||||
|
const side = Math.abs(Math.cos(a)) > 0.3;
|
||||||
|
const style: React.CSSProperties = side
|
||||||
|
? Math.cos(a) > 0
|
||||||
|
? { left: `${x + 4.4}%`, top: `${y}%`, transform: "translateY(-50%)" }
|
||||||
|
: { right: `${100 - x + 4.4}%`, top: `${y}%`, transform: "translateY(-50%)" }
|
||||||
|
: { left: `${x}%`, top: `${y - 5.6}%`, transform: "translate(-50%, -50%)" };
|
||||||
|
return (
|
||||||
|
<span key={s.label} className="wne-src" style={style}>
|
||||||
|
{s.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<div className="wne-live">
|
||||||
|
<span className="wne-live-dot" />
|
||||||
|
<span className="wne-live-text">MILETRUTH ENGINE LIVE</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="wne-count">
|
||||||
|
<b ref={countRef}>1,284,392</b> shipments analysed
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="wne-insights">
|
||||||
|
{insights.map((c) => (
|
||||||
|
<div key={c.id} className="wne-card">
|
||||||
|
<span className="wne-card-head">{c.head}</span>
|
||||||
|
<span className={`wne-card-value${c.good ? " good" : ""}`}>{c.value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="wne-metrics">
|
||||||
|
<div className="wne-metric">
|
||||||
|
<span className="wne-metric-label">Throughput</span>
|
||||||
|
<span className="wne-metric-value">
|
||||||
|
<span ref={rateRef}>1.18k</span>
|
||||||
|
<small>ev/s</small>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<canvas ref={sparkRef} className="wne-spark" />
|
||||||
|
<div className="wne-metric">
|
||||||
|
<span className="wne-metric-label">Confidence</span>
|
||||||
|
<span className="wne-metric-value" ref={confRef}>
|
||||||
|
97.4%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="wne-metric">
|
||||||
|
<span className="wne-metric-label">SLA Protected</span>
|
||||||
|
<span className="wne-metric-value" ref={slaRef}>
|
||||||
|
99.2%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="wne-sr">
|
||||||
|
MileTruth AI ingesting live logistics telemetry from orders, fleet, routes, traffic,
|
||||||
|
weather and proof-of-delivery feeds, and emitting routing recommendations in real time.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user