diff --git a/component-library/index.html b/component-library/index.html index 98e9fe0..ebcb320 100644 --- a/component-library/index.html +++ b/component-library/index.html @@ -4,6 +4,10 @@ SolenOS – Messdienstleistungen einfach gemacht + + + + + Math.min(high, Math.max(low, value)) + +const pad2 = (n: number) => String(n).padStart(2, "0") + +/** + * The whole layout, as one pure function: every card gets an absolute frame, so + * a state change is a transform and a size to interpolate instead of a reflow. + * Grid, rail and open card all fall out of the same three numbers – container + * width, measured teaser height, measured open-copy height. + */ +function planLayout({ + count, + width, + teaserHeights, + copyHeights, + active, + side, +}: { + count: number + width: number + /** natural height of each teaser face, measured at grid card width */ + teaserHeights: number[] + /** natural height of each open-card copy block, measured at open width */ + copyHeights: number[] + active: number + side: Side +}): Plan { + if (count === 0 || width <= 0) return EMPTY_PLAN + + const cols = width >= THREE_COL ? 3 : width >= TWO_COL ? 2 : 1 + const cardW = (width - (cols - 1) * GRID_GAP) / cols + const rows = Math.ceil(count / cols) + + /* only trust the measurement once every card has reported, so a half-mounted + grid never bakes a too-short row height into the open card's height */ + const measured = + teaserHeights.length === count && teaserHeights.every((h) => h > 0) + ? Math.max(...teaserHeights) + : 0 + const rowH = measured || FALLBACK_ROW_H + const gridH = rows * rowH + (rows - 1) * GRID_GAP + + const railW = (width - GRID_GAP) / 3 + const railCount = Math.max(0, count - 1) + const split = active >= 0 && cols > 1 && railCount > 0 + const openW = split ? width - railW - GRID_GAP : width + const spread = openW >= SPREAD_MIN + const pad = spread ? 32 : 24 + const visualW = spread ? Math.round(openW * VISUAL_SHARE) : 0 + const copyH = (i: number) => (copyHeights[i] ?? 0) + 2 * pad + + const layout = { cols, split, cardW, openW, railW, pad, visualW, spread } + + /* no rail: either the plain grid, or a single column where the open card has + nowhere to go but down */ + if (!split) { + const frames: Frame[] = [] + const height = (i: number) => + i === active ? Math.max(copyH(i), rowH) : rowH + + if (cols === 1) { + let y = 0 + for (let i = 0; i < count; i++) { + frames.push({ x: 0, y, w: cardW, h: height(i) }) + y += frames[i].h + GRID_GAP + } + return { ...layout, frames, height: y - GRID_GAP } + } + + let bottom = 0 + for (let i = 0; i < count; i++) { + const frame = { + x: (i % cols) * (cardW + GRID_GAP), + y: Math.floor(i / cols) * (rowH + GRID_GAP), + w: cardW, + h: height(i), + } + frames.push(frame) + bottom = Math.max(bottom, frame.y + frame.h) + } + return { ...layout, frames, height: Math.max(gridH, bottom) } + } + + /* split: the open card keeps the grid's footprint, the rest becomes a column */ + const railH = clamp( + (gridH - (railCount - 1) * RAIL_GAP) / railCount, + RAIL_MIN_H, + RAIL_MAX_H + ) + const railTotal = railCount * railH + (railCount - 1) * RAIL_GAP + const height = Math.max(gridH, railTotal, copyH(active)) + const openX = side === "left" ? 0 : railW + GRID_GAP + const railX = side === "left" ? openW + GRID_GAP : 0 + const railTop = (height - railTotal) / 2 + + const frames: Frame[] = [] + let slot = 0 + for (let i = 0; i < count; i++) { + if (i === active) { + frames.push({ x: openX, y: 0, w: openW, h: height }) + continue + } + frames.push({ + x: railX, + y: railTop + slot * (railH + RAIL_GAP), + w: railW, + h: railH, + }) + slot++ + } + + return { ...layout, frames, height } +} + +type Metrics = { width: number; teaser: number[]; copy: number[] } + +const sameNumbers = (a: number[], b: number[]) => + a.length === b.length && a.every((v, i) => v === b[i]) + +const sameMetrics = (a: Metrics, b: Metrics) => + a.width === b.width && + sameNumbers(a.teaser, b.teaser) && + sameNumbers(a.copy, b.copy) + +/** + * Reads the container width plus the natural height of the two faces that have + * to size themselves from real copy. Both are laid out at the width they will + * have in their own state, so a measurement never depends on the state the grid + * is in – and no card reflows while it travels. + * + * Re-reading on every width change keeps that honest: the pass runs in a layout + * effect, so the corrected heights are in before the browser paints. + */ +function useGridMetrics(count: number) { + const hostRef = React.useRef(null) + const teaserRefs = React.useRef<(HTMLElement | null)[]>([]) + const copyRefs = React.useRef<(HTMLElement | null)[]>([]) + const [metrics, setMetrics] = React.useState({ + width: 0, + teaser: [], + copy: [], + }) + + React.useLayoutEffect(() => { + const host = hostRef.current + if (!host) return + + const heights = (nodes: (HTMLElement | null)[]) => + nodes + .slice(0, count) + .map((node) => + node ? Math.ceil(node.getBoundingClientRect().height) : 0 + ) + + const read = () => + setMetrics((prev) => { + const next: Metrics = { + width: host.clientWidth, + teaser: heights(teaserRefs.current), + copy: heights(copyRefs.current), + } + return sameMetrics(prev, next) ? prev : next + }) + + read() + + const observer = new ResizeObserver(read) + observer.observe(host) + for (const node of [...teaserRefs.current, ...copyRefs.current]) { + if (node) observer.observe(node) + } + return () => observer.disconnect() + /* width is a dep on purpose: a resize changes the width the faces are laid + out at, so their heights have to be read again in the same pre-paint pass */ + }, [count, metrics.width]) + + return { hostRef, teaserRefs, copyRefs, metrics } +} + +/** frames travel on ease-out quint: off immediately, settling without overshoot */ +const FRAME_MOTION = + "transition-[transform,width,height] duration-[560ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none" + +const FACE_MOTION = + "transition-[opacity,translate,scale] duration-300 ease-out motion-reduce:transition-none" + +/** the leaving face clears out at once, the arriving one lets the box move first */ +const faceMotion = (visible: boolean) => + cn( + FACE_MOTION, + visible + ? "opacity-100 translate-y-0 delay-[140ms]" + : "opacity-0 translate-y-1" + ) + +/** faces are decoration for the button underneath – only their links take a click */ +const FACE_BASE = "pointer-events-none absolute top-0 left-0 z-20" + +/** grid face: mark, title, full copy – the card as the static section had it */ +function TeaserFace({ + card, + width, + visible, + innerRef, +}: { + card: ExpandingCard + width: number + visible: boolean + innerRef: (node: HTMLElement | null) => void +}) { + return ( +
+ {card.media} +

+ {card.title} +

+

+ {card.text} +

+
+ ) +} + +/** rail face: one line per card, the mark shrunk to keep the row scannable */ +function RailFace({ + card, + width, + visible, +}: { + card: ExpandingCard + width: number + visible: boolean +}) { + return ( +
+ + {card.media} + + {/* long compounds ("Verbrauchsinformation") have to be allowed to break, + or the clamp never gets to put its ellipsis anywhere */} +

+ {card.title} +

+
+ ) +} + +/** + * Open face: index, title, the copy in full, the detail bullets and the deep + * link. Padding is inline because the layout has to add it to the measured copy + * height to know how tall the card must be on a single column. + */ +function OpenFace({ + card, + index, + total, + plan, + visible, + panelId, + innerRef, +}: { + card: ExpandingCard + index: number + total: number + plan: Plan + visible: boolean + panelId: string + innerRef: (node: HTMLElement | null) => void +}) { + const { openW, pad, visualW, spread } = plan + return ( +
+
+

+ {pad2(index + 1)} + + {pad2(total)} +

+

+ {card.title} +

+

+ {card.text} +

+ {card.points?.length ? ( + + ) : null} + {card.to ? ( + + ) : null} + {!spread && card.visual ? ( + {card.visual} + ) : null} +
+
+ ) +} + +/** + * Toggle disc, borrowed from the FAQ list so both disclosures speak the same + * language: hairline teal plus that fills in and turns into an × once its card + * opens, and slides to the middle of the row while the card is in the rail. + */ +function CardToggleMark({ face }: { face: Face }) { + return ( + + + + ) +} + +function GridCard({ + card, + index, + total, + face, + frame, + plan, + delay, + animate, + panelId, + onToggle, + teaserRef, + copyRef, +}: { + card: ExpandingCard + index: number + total: number + face: Face + frame: Frame | undefined + plan: Plan + delay: number + animate: boolean + panelId: string + onToggle: () => void + teaserRef: (node: HTMLElement | null) => void + copyRef: (node: HTMLElement | null) => void +}) { + const open = face === "open" + + return ( +
+
+ + {plan.visualW && card.visual ? ( + + {card.visual} + + ) : null} + +
+
+ ) +} + +/** + * Card grid that unpacks in place. Clicking a card grows it to the full height + * of the grid it was part of, while the remaining cards gather into a column of + * a third of the width beside it – on the side the open card came *from*, so + * the card you picked stays where you clicked it and everything else moves out + * of its way. Opening a card out of the rail therefore flips the composition. + * + * Every card is absolutely positioned from measured geometry, which keeps a + * state change to a transform plus a size: the faces are pre-laid out at the + * width they end up with, so no copy re-wraps mid-flight. Under two columns the + * split has no room, and the open card grows in place instead. + */ +function ExpandingCardGrid({ + items, + className, +}: { + items: ExpandingCard[] + className?: string +}) { + const [open, setOpen] = React.useState<{ id: string; side: Side } | null>( + null + ) + const { hostRef, teaserRefs, copyRefs, metrics } = useGridMetrics( + items.length + ) + const uid = React.useId() + + const active = open ? items.findIndex((item) => item.id === open.id) : -1 + const plan = planLayout({ + count: items.length, + width: metrics.width, + teaserHeights: metrics.teaser, + copyHeights: metrics.copy, + active, + side: open?.side ?? "left", + }) + + /* transitions only after the measured geometry has been painted once, and + never for the reflow of a resize – dragging a window edge should track the + pointer, not trail half a second behind it */ + const [ready, setReady] = React.useState(false) + React.useEffect(() => setReady(true), []) + const painted = React.useRef(0) + const resized = painted.current !== metrics.width + /* written after the commit, not during the render, so React's double + invocation in development cannot swallow the flag */ + React.useEffect(() => { + painted.current = metrics.width + }) + const animate = ready && !resized + + const toggle = (index: number) => { + if (index === active) { + setOpen(null) + return + } + const frame = plan.frames[index] + /* the card keeps its side: whichever half it is sitting in when clicked is + the half it opens into, so the rail always forms on the other one */ + const onRight = frame + ? frame.x + frame.w / 2 > metrics.width / 2 + 1 + : false + setOpen({ id: items[index].id, side: onRight ? "right" : "left" }) + } + + let slot = 0 + + return ( +
{ + if (event.key === "Escape" && active >= 0) { + event.stopPropagation() + setOpen(null) + } + }} + style={{ height: plan.height || undefined }} + className={cn( + "relative", + animate + ? "transition-[height] duration-[560ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none" + : "transition-none", + className + )} + > + {items.map((card, index) => { + const face: Face = + index === active ? "open" : plan.split ? "rail" : "brief" + /* the rail arrives as a cascade; everything else moves at once */ + const delay = face === "rail" ? slot++ * 26 : 0 + + return ( + toggle(index)} + teaserRef={(node) => { + teaserRefs.current[index] = node + }} + copyRef={(node) => { + copyRefs.current[index] = node + }} + /> + ) + })} +
+ ) +} + +export { ExpandingCardGrid } +export type { ExpandingCard } diff --git a/component-library/src/pages/home.tsx b/component-library/src/pages/home.tsx index b00b351..ea77fb8 100644 --- a/component-library/src/pages/home.tsx +++ b/component-library/src/pages/home.tsx @@ -17,6 +17,10 @@ import { InfoStrip } from "@/components/solenos/info-strip" import { NumberedList, StepFlow } from "@/components/solenos/process-steps" import { Checklist } from "@/components/solenos/checklist" import { CtaBanner } from "@/components/solenos/cta-banner" +import { + ExpandingCardGrid, + type ExpandingCard, +} from "@/components/solenos/expanding-card-grid" import { FaqAccordion, type FaqItem } from "@/components/solenos/faq-accordion" import { NoticeArt, NoticeBanner } from "@/components/solenos/notice-banner" import { GermanyIcon } from "@/components/solenos/line-icons" @@ -67,32 +71,89 @@ const infoStripItems = [ }, ] -const operationsScope = [ +const operationsScope: ExpandingCard[] = [ { + id: "uvi", media: , + visual: ( + + ), title: "Unterjährige Verbrauchsinformation", text: "Verbrauchsdaten werden automatisch erfasst und die regelmäßigen Verbrauchsinformationen für die Bewohner bereitgestellt.", + points: [ + "monatliche Erzeugung der Verbrauchsinformation", + "Bereitstellung im Mieterportal für jede Wohnung", + "Nutzerwechsel ohne Ablesetermin vor Ort", + ], + to: "/produkte/uvi", }, { + id: "heizkostenabrechnung", media: ( ), + visual: ( + + ), title: "Heizkostenabrechnung", text: "Die erfassten Verbrauchsdaten bilden die Grundlage für die jährliche Heizkostenabrechnung.", + points: [ + "Verteilung der Wärme- und Warmwasserkosten nach HeizkostenV", + "Berücksichtigung von Nutzerwechseln und Leerständen", + "Einzelabrechnung je Wohnung mit den Pflichtangaben nach § 6a Absatz 3", + ], + to: "/produkte/heizkostenabrechnung", }, { + id: "rauchwarnmelder", media: ( ), + visual: ( + + ), title: "Rauchwarnmelder", text: "Rauchwarnmelder können gemeinsam mit der übrigen Gebäudetechnik geplant, installiert und zentral verwaltet werden.", + points: [ + "Rauchwarnmelder inklusive Montage und Eigentumsübertragung", + "Bedarfsermittlung je Wohnung aus den Gebäudedaten", + "regelmäßige Ferninspektion und Störungsmeldungen", + ], + to: "/produkte/rauchwarnmelder", }, { + id: "messtechnik", media: , + visual: ( + + ), title: "Fernauslesbare Messtechnik", text: "Wärme- und Wasserverbrauch werden über kompatible Geräte automatisch erfasst.", + points: [ + "Wärmezähler, Heizkostenverteiler und Wasserzähler messen laufend je Wohnung", + "Geräte sind fernablesbar, also ohne Zugang zur Wohnung auslesbar", + "die Funkschnittstelle folgt einem offenen Standard (OMS)", + ], + to: "/produkte/messtechnik-infrastruktur", }, { + id: "gateway", media: ( ), + visual: ( + + ), title: "Gateway und Datenübertragung", text: "Die Messdaten werden zentral gesammelt und an SolenOS übertragen.", + points: [ + "ein Gateway je Gebäude empfängt die Funkpakete aller angebundenen Geräte", + "Übertragung per Mobilfunk, ohne Internetanschluss im Gebäude", + "TLS-verschlüsselte Übertragung an die Plattform", + ], + to: "/produkte/messtechnik-infrastruktur", + linkLabel: "Messtechnik und Infrastruktur", }, { + id: "portale", media: ( ), + visual: ( + + ), title: "Portale und Verwaltung", text: "Gebäude, Wohnungen, Geräte und Verbrauchsdaten werden zentral verwaltet. Bewohner erhalten Zugriff auf die für sie vorgesehenen Informationen.", + points: [ + "Portalzugang für Eigentümer und Verwaltung", + "Bewohner rufen ihre Information im Mieterportal ab", + "rollenbasierte Zugriffsrechte", + ], }, ] @@ -443,21 +531,7 @@ export default function HomePage() { title="Alles, was für den laufenden Messbetrieb benötigt wird" lead="SolenOS verbindet Messtechnik, Datenerfassung und Software zu einem durchgängigen System. Statt einzelne Komponenten selbst zusammenzustellen und miteinander zu verbinden, erhalten Sie eine Lösung, bei der Bestellung, Hardware, Konfiguration und laufender Betrieb zusammenspielen." /> -
- {operationsScope.map((item) => ( - - - {item.media} -

- {item.title} -

-

- {item.text} -

-
-
- ))} -
+