Refine simulation branding and airflow

This commit is contained in:
2026-06-20 00:11:52 -06:00
parent c5abc8d951
commit 26622e70f8
6 changed files with 278 additions and 45 deletions
+10
View File
@@ -8,6 +8,7 @@
"name": "simulation-website",
"version": "0.0.0",
"dependencies": {
"@fontsource/michroma": "^5.2.8",
"lucide-react": "^1.21.0",
"react": "^19.2.6",
"react-dom": "^19.2.6"
@@ -429,6 +430,15 @@
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@fontsource/michroma": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource/michroma/-/michroma-5.2.8.tgz",
"integrity": "sha512-52Ml9TU/yC5YluqHxXqLK/AVUWbIeI3lCTZcTRnIkvfKHPZ0GSvSFi49ti872oCdo+YAMUk8RIfxVxXa6Cjigw==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@humanfs/core": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
+1
View File
@@ -10,6 +10,7 @@
"preview": "vite preview"
},
"dependencies": {
"@fontsource/michroma": "^5.2.8",
"lucide-react": "^1.21.0",
"react": "^19.2.6",
"react-dom": "^19.2.6"
+115 -39
View File
@@ -14,13 +14,12 @@
.topbar {
display: grid;
grid-template-columns: minmax(420px, 1.35fr) 230px minmax(300px, 360px) minmax(220px, 0.7fr);
grid-template-columns: minmax(610px, 1.45fr) 230px minmax(300px, 360px) minmax(220px, 0.7fr);
gap: 28px;
align-items: center;
margin-bottom: 16px;
}
.brand-lockup,
.mode-card,
.weather,
.runtime-status {
@@ -28,20 +27,39 @@
align-items: center;
}
.brand-lockup {
display: grid;
grid-template-columns: auto 1px minmax(230px, 1fr);
gap: 28px;
align-items: center;
}
.brand {
font-size: clamp(54px, 4vw, 76px);
display: flex;
align-items: center;
gap: 14px;
line-height: 0.82;
}
.brand img {
width: 66px;
height: 66px;
object-fit: contain;
filter: drop-shadow(0 0 16px rgba(32, 232, 255, 0.18));
}
.brand span {
font-family: Michroma, Eurostile, "Arial Black", sans-serif;
font-size: clamp(38px, 2.55vw, 48px);
font-weight: 700;
letter-spacing: -0.04em;
background: linear-gradient(100deg, #3f79ff 5%, #00d9ff 62%, #22efc6 100%);
-webkit-background-clip: text;
color: transparent;
letter-spacing: 0.015em;
color: #f4f8ff;
text-shadow: 0 0 22px rgba(30, 226, 255, 0.16);
}
.divider {
width: 1px;
height: 48px;
margin: 0 28px;
background: linear-gradient(transparent, rgba(95, 201, 255, 0.42), transparent);
}
@@ -392,13 +410,27 @@ h1 {
transition: transform 0.6s ease, filter 0.6s ease;
}
.floorplan-wrap::before {
content: '';
position: absolute;
inset: 5% 3% 3%;
z-index: 0;
background:
radial-gradient(circle at 28% 44%, rgba(35, 134, 255, 0.18), transparent 22%),
radial-gradient(circle at 67% 48%, rgba(46, 238, 188, 0.12), transparent 19%),
radial-gradient(circle at 75% 72%, rgba(245, 163, 83, 0.14), transparent 17%);
filter: blur(20px);
opacity: 0.9;
pointer-events: none;
}
.floorplan-wrap.zoomed {
transform: scale(1.035);
}
.view-tab {
position: absolute;
z-index: 4;
z-index: 7;
top: -9px;
left: 50%;
transform: translateX(-50%);
@@ -416,11 +448,24 @@ h1 {
}
.floorplan {
position: relative;
z-index: 2;
width: 100%;
height: 600px;
overflow: visible;
}
.airflow-canvas {
position: absolute;
inset: 0;
z-index: 4;
width: 100%;
height: 100%;
pointer-events: none;
mix-blend-mode: screen;
opacity: 0.94;
}
.foundation {
fill: rgba(166, 188, 216, 0.24);
stroke: rgba(201, 221, 245, 0.38);
@@ -431,11 +476,12 @@ h1 {
fill: url(#planDepth);
stroke: rgba(185, 214, 247, 0.56);
stroke-width: 0.45;
filter: drop-shadow(0 12px 12px rgba(0, 0, 0, 0.45));
}
.room rect {
fill: rgba(44, 72, 103, 0.48);
stroke: rgba(154, 187, 222, 0.26);
fill: rgba(44, 72, 103, 0.54);
stroke: rgba(185, 214, 247, 0.31);
stroke-width: 0.45;
}
@@ -450,9 +496,10 @@ h1 {
.wall {
fill: none;
stroke: rgba(196, 215, 238, 0.72);
stroke-width: 1.6;
stroke: rgba(213, 232, 255, 0.78);
stroke-width: 1.9;
stroke-linecap: square;
filter: drop-shadow(0 0 3px rgba(142, 201, 255, 0.2));
}
.hub {
@@ -471,19 +518,20 @@ h1 {
.flow {
fill: none;
stroke: rgba(42, 137, 255, 0.52);
stroke-width: 3.6;
stroke: rgba(42, 137, 255, 0.24);
stroke-width: 2.2;
stroke-linecap: round;
opacity: 0.92;
opacity: 0.68;
}
.flow-tracer {
fill: none;
stroke: #25e9ff;
stroke-width: 1.2;
stroke-width: 0.8;
stroke-linecap: round;
stroke-dasharray: 9 17;
stroke-dasharray: 3 13;
animation: flowRun 2.4s linear infinite;
opacity: 0.58;
}
.flow-2,
@@ -519,15 +567,17 @@ h1 {
.room-badge {
position: absolute;
z-index: 5;
z-index: 8;
transform: translate(-50%, -50%);
min-width: 118px;
padding: 8px 12px;
min-width: 112px;
padding: 8px 11px 7px;
border-radius: 8px;
background: linear-gradient(180deg, rgba(5, 22, 35, 0.72), rgba(2, 12, 20, 0.42));
background: linear-gradient(180deg, rgba(5, 22, 35, 0.84), rgba(2, 12, 20, 0.58));
border: 1px solid rgba(76, 177, 238, 0.1);
color: #f2f8ff;
text-shadow: 0 2px 6px rgba(0, 0, 0, 0.75);
pointer-events: none;
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.18);
}
.room-badge strong,
@@ -539,12 +589,13 @@ h1 {
}
.room-badge strong {
font-size: 13px;
font-size: 12px;
letter-spacing: 0.015em;
}
.room-badge span {
display: block;
font-size: 29px;
font-size: 28px;
line-height: 1.05;
font-weight: 800;
}
@@ -565,7 +616,7 @@ h1 {
.vent-callout {
position: absolute;
z-index: 6;
z-index: 8;
display: grid;
place-items: center;
min-width: 55px;
@@ -596,13 +647,14 @@ h1 {
font-size: 12px;
}
.callout-0 { left: 2%; top: 9%; }
.callout-1 { left: 48%; top: 5%; }
.callout-2 { right: 1%; top: 9%; }
.callout-3 { left: -1%; top: 40%; }
.callout-0 { left: 2%; top: 10%; }
.callout-1 { left: 50%; top: 6%; }
.callout-2 { right: 1%; top: 10%; }
.callout-3 { left: -1%; top: 41%; }
.callout-4 { left: -2%; top: 68%; }
.callout-5 { left: 49%; bottom: 0; }
.callout-6 { right: 0; top: 67%; }
.callout-5 { left: 50%; bottom: 0; }
.callout-6 { right: 0; top: 41%; }
.callout-7 { right: 0; top: 68%; }
.callout-0::after,
.callout-3::after,
@@ -616,7 +668,8 @@ h1 {
}
.callout-2::after,
.callout-6::after { right: 100%; }
.callout-6::after,
.callout-7::after { right: 100%; }
.vent-panel {
padding-bottom: 11px;
@@ -629,7 +682,7 @@ h1 {
.vent-head,
.vent-row {
display: grid;
grid-template-columns: 1.35fr 54px 68px 74px 20px;
grid-template-columns: minmax(112px, 1.25fr) 48px 72px 74px 20px;
align-items: center;
column-gap: 8px;
}
@@ -649,6 +702,12 @@ h1 {
font-size: 13px;
}
.vent-row span,
.vent-row b,
.vent-row small {
white-space: nowrap;
}
.vent-row b,
.vent-row small {
font-weight: 600;
@@ -674,8 +733,8 @@ h1 {
.distribution {
display: grid;
grid-template-columns: 142px 1fr;
gap: 10px;
grid-template-columns: 138px 1fr;
gap: 12px;
align-items: center;
padding: 14px 16px 18px;
}
@@ -724,7 +783,7 @@ h1 {
.distribution li {
display: grid;
grid-template-columns: 10px 1fr auto;
grid-template-columns: 10px minmax(80px, 1fr) minmax(74px, auto);
gap: 7px;
align-items: center;
}
@@ -991,19 +1050,36 @@ h1 {
@media (max-width: 760px) {
.simulation-shell {
padding: 20px 14px 24px;
overflow: visible;
overflow-x: clip;
}
.brand-lockup {
grid-template-columns: 1fr;
gap: 8px;
align-items: flex-start;
}
.brand {
font-size: 45px;
gap: 8px;
}
.brand img {
width: 50px;
height: 50px;
}
.brand span {
font-size: 28px;
}
.divider {
margin: 0 14px;
display: none;
}
.mode-card,
.panel,
.demo-strip {
max-width: calc(100vw - 28px);
}
h1 {
+150 -6
View File
@@ -12,7 +12,8 @@ import {
Wind,
Zap,
} from 'lucide-react'
import { useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import vynteMark from './assets/vynte-mark.png'
import './App.css'
type Room = {
@@ -74,6 +75,28 @@ const flowPaths = [
'M50 53 C50 43 49 37 49 29 C48 19 50 14 52 11',
]
const flowCurves = [
[[50, 53], [44, 51], [38, 45], [31, 39], [24, 31], [18, 19]],
[[50, 53], [54, 46], [55, 37], [54, 27], [53, 18], [54, 11]],
[[50, 53], [58, 51], [65, 47], [72, 39], [80, 29], [84, 18]],
[[50, 53], [58, 54], [66, 52], [71, 45], [79, 40], [86, 42]],
[[50, 53], [43, 55], [35, 54], [29, 50], [22, 45], [14, 43]],
[[50, 53], [44, 59], [38, 63], [31, 68], [23, 75], [17, 80]],
[[50, 53], [51, 61], [52, 70], [52, 79], [52, 87]],
[[50, 53], [58, 58], [66, 63], [72, 69], [79, 74], [86, 75]],
] as const
const ventCallouts = [
{ value: 65, label: 'Open' },
{ value: 50, label: 'Open' },
{ value: 55, label: 'Open' },
{ value: 75, label: 'Open' },
{ value: 60, label: 'Open' },
{ value: 50, label: 'Open' },
{ value: 70, label: 'Open' },
{ value: 45, label: 'Open' },
]
function PanelTitle({ title, icon }: { title: string; icon?: React.ReactNode }) {
return <h2 className="panel-title">{icon ?? null}{title}<Info size={14} /></h2>
}
@@ -139,10 +162,128 @@ function Donut() {
)
}
function AirflowCanvas() {
const canvasRef = useRef<HTMLCanvasElement | null>(null)
useEffect(() => {
const canvas = canvasRef.current
const parent = canvas?.parentElement
if (!canvas || !parent) return
const context = canvas.getContext('2d')
if (!context) return
const colors = ['30, 140, 255', '31, 231, 255', '45, 246, 188', '245, 163, 83']
const particles = Array.from({ length: 145 }, (_, index) => ({
path: index % flowCurves.length,
offset: Math.random(),
speed: 0.0007 + Math.random() * 0.0017,
lane: (Math.random() - 0.5) * 14,
radius: 0.8 + Math.random() * 2.1,
alpha: 0.1 + Math.random() * 0.24,
}))
const resize = () => {
const { width, height } = parent.getBoundingClientRect()
const ratio = window.devicePixelRatio || 1
canvas.width = Math.round(width * ratio)
canvas.height = Math.round(height * ratio)
canvas.style.width = `${width}px`
canvas.style.height = `${height}px`
context.setTransform(ratio, 0, 0, ratio, 0, 0)
}
const sample = (curve: readonly (readonly [number, number])[], t: number, width: number, height: number) => {
const scaled = curve.map(([x, y]) => [x * width / 100, y * height / 100])
const segment = Math.min(Math.floor(t * (scaled.length - 1)), scaled.length - 2)
const local = t * (scaled.length - 1) - segment
const [x1, y1] = scaled[segment]
const [x2, y2] = scaled[segment + 1]
const angle = Math.atan2(y2 - y1, x2 - x1)
const x = x1 + (x2 - x1) * local
const y = y1 + (y2 - y1) * local
return {
x,
y,
angle,
normalX: Math.cos(angle + Math.PI / 2),
normalY: Math.sin(angle + Math.PI / 2),
}
}
let frame = 0
let animation = 0
const draw = () => {
const width = canvas.clientWidth
const height = canvas.clientHeight
context.clearRect(0, 0, width, height)
context.globalCompositeOperation = 'lighter'
flowCurves.forEach((curve, index) => {
const color = index === 7 ? colors[3] : index === 3 ? colors[2] : colors[index % 3]
for (let lane = -2; lane <= 2; lane += 1) {
context.beginPath()
curve.forEach((_, pointIndex) => {
const sampled = sample(curve, pointIndex / (curve.length - 1), width, height)
const wave = Math.sin(frame * 0.018 + pointIndex * 1.4 + lane) * 6
const x = sampled.x + sampled.normalX * (lane * 4 + wave)
const y = sampled.y + sampled.normalY * (lane * 4 + wave)
if (pointIndex === 0) context.moveTo(x, y)
else context.lineTo(x, y)
})
context.strokeStyle = `rgba(${color}, ${0.045 + Math.abs(lane) * 0.018})`
context.lineWidth = 3.2 - Math.abs(lane) * 0.35
context.shadowBlur = 24
context.shadowColor = `rgba(${color}, 0.65)`
context.stroke()
}
})
particles.forEach((particle) => {
particle.offset = (particle.offset + particle.speed) % 1
const curve = flowCurves[particle.path]
const sampled = sample(curve, particle.offset, width, height)
const tail = sample(curve, Math.max(0, particle.offset - 0.035), width, height)
const color = particle.path === 7 ? colors[3] : particle.path === 3 ? colors[2] : colors[particle.path % 3]
const x = sampled.x + sampled.normalX * particle.lane
const y = sampled.y + sampled.normalY * particle.lane
const tx = tail.x + tail.normalX * particle.lane
const ty = tail.y + tail.normalY * particle.lane
context.beginPath()
context.moveTo(tx, ty)
context.lineTo(x, y)
context.strokeStyle = `rgba(${color}, ${particle.alpha})`
context.lineWidth = particle.radius
context.shadowBlur = 18
context.shadowColor = `rgba(${color}, 0.8)`
context.stroke()
})
context.globalCompositeOperation = 'source-over'
frame += 1
animation = requestAnimationFrame(draw)
}
resize()
const observer = new ResizeObserver(resize)
observer.observe(parent)
draw()
return () => {
cancelAnimationFrame(animation)
observer.disconnect()
}
}, [])
return <canvas className="airflow-canvas" ref={canvasRef} aria-hidden="true" />
}
function FloorPlan({ selectedView }: { selectedView: string }) {
return (
<section className={`floorplan-wrap ${selectedView === 'room' ? 'zoomed' : ''}`} aria-label="Whole-home airflow simulation">
<div className="view-tab">Whole-Home View</div>
<AirflowCanvas />
<svg className="floorplan" viewBox="0 0 100 100" role="img" aria-label="Top-down home floorplan with animated airflow">
<defs>
<filter id="flowGlow" x="-40%" y="-40%" width="180%" height="180%">
@@ -195,10 +336,10 @@ function FloorPlan({ selectedView }: { selectedView: string }) {
<small>{room.cfm} CFM <CheckCircle2 size={12} /></small>
</div>
))}
{rooms.slice(0, 7).map((room, index) => (
<div className={`vent-callout callout-${index}`} key={`${room.id}-callout`} style={{ '--vent': `${room.vent}%` } as React.CSSProperties}>
<b>{room.vent}%</b>
<span>Open</span>
{ventCallouts.map((callout, index) => (
<div className={`vent-callout callout-${index}`} key={`${callout.value}-${index}`} style={{ '--vent': `${callout.value}%` } as React.CSSProperties}>
<b>{callout.value}%</b>
<span>{callout.label}</span>
</div>
))}
</section>
@@ -222,7 +363,10 @@ function App() {
<main className="simulation-shell">
<header className="topbar">
<div className="brand-lockup">
<div className="brand">vynte</div>
<div className="brand">
<img src={vynteMark} alt="" />
<span>VYNTE</span>
</div>
<div className="divider" />
<div>
<h1>Algorithm Simulation</h1>
Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

+2
View File
@@ -1,3 +1,5 @@
@import '@fontsource/michroma/400.css';
:root {
--bg: #020b13;
--text: #c7d6e3;