Components
Conditions graph
Cloud cover, the moon's altitude, the moon rise marker, the prime view window and the current time on one night.
Preview
Cloud coverMoon altitudePrime viewNow
Variants
Pick a variant with the controls. The preview and both code blocks follow.
Cloud cover
Moon altitude
Moon rise
Prime view
Now
Variant.tsx
<AppConditionsGraph />Implementation
The React tab is this site's own component, the same source that draws the preview, styled by the CSS below it. The Flutter tab is the Dart a screen builds it with, from the app's tokens.
Usage
<AppConditionsGraph /><AppConditionsGraph layers={{ cloud: true, moon: false, prime: false }} />app-graphs.tsx · smooth
/** Cubic segments with tension 0.2, exactly as _drawCloudCover builds them. */function smooth(points: Pt[]): string { let d = `M${points[0][0]} ${points[0][1]}`; for (let i = 0; i < points.length - 1; i++) { const p0 = points[Math.max(0, i - 1)]; const p1 = points[i]; const p2 = points[i + 1]; const p3 = points[Math.min(points.length - 1, i + 2)]; const c1 = [p1[0] + (p2[0] - p0[0]) * 0.2, p1[1] + (p2[1] - p0[1]) * 0.2]; const c2 = [p2[0] - (p3[0] - p1[0]) * 0.2, p2[1] - (p3[1] - p1[1]) * 0.2]; d += ` C${c1[0]} ${c1[1]} ${c2[0]} ${c2[1]} ${p2[0]} ${p2[1]}`; } return d;}app-graphs.tsx · AppConditionsGraph
/** * ConditionsGraph: grid, a smoothed cloud area, the moon's altitude area, the moon rise marker, the prime view * window and the NOW marker. Colours are stops at Pogson opacity steps; sizes are Fibonacci numbers. */export function AppConditionsGraph({ layers = {}, height = 233, cloudData = CLOUD_HOURLY }: { layers?: GraphLayers; height?: number; cloudData?: readonly number[] }) { const { cloud = true, moon = true, moonRise = true, prime = true, now = true } = layers; const [ref, w] = useElementWidth<HTMLDivElement>(560); const h = height - LABEL_H; const span = NIGHT_END - NIGHT_START; const x = (m: number): number => ((m - NIGHT_START) / span) * w; const cloudPts: Pt[] = cloudData.map((c, i) => [x(NIGHT_START + (i * span) / (cloudData.length - 1)), h - (c / 100) * h] as const); const cloudFill = `M${cloudPts[0][0]} ${h} L${cloudPts[0][0]} ${cloudPts[0][1]} ${smooth(cloudPts).replace(/^M\S+ \S+/, "")} L${cloudPts.at(-1)![0]} ${h} L${w} ${h} Z`; const moonPts: Pt[] = []; for (let m = NIGHT_START; m <= NIGHT_END; m += 15) moonPts.push([x(m), h - (Math.max(0, moonAltitude(m)) / 90) * h]); const moonLine = "M" + moonPts.map(([px, py]) => `${px} ${py}`).join(" L"); const win = prime ? primeView() : null; const riseX = x(MOON_RISE_MINUTES); const labelY = h * 0.75; const nowX = x(NOW); const nowTop = h * 0.35; const winStart = win ? x(win.from) : 0; const winEnd = win ? x(win.to) : 0; const winMid = Math.max(F.f55, Math.min(w - F.f55, (winStart + winEnd) / 2)); return ( <div ref={ref} className="app-ui app-graph" style={{ height }}> <svg viewBox={`0 0 ${w} ${height}`} height={height} role="img" aria-label="Conditions graph for an example night"> <GraphDefs /> {[1, 2, 3].map((i) => <line key={`h${i}`} x1={0} x2={w} y1={(h * i) / 4} y2={(h * i) / 4} stroke={stop(GRAPH.ink, 6)} />)} {[1, 2, 3, 4].map((i) => <line key={`v${i}`} y1={0} y2={h} x1={(w * i) / 4} x2={(w * i) / 4} stroke={stop(GRAPH.ink, 6)} />)} {cloud && ( <g> <path d={cloudFill} fill="url(#g-cloud)" /> <path d={smooth(cloudPts)} fill="none" stroke={stop(GRAPH.cloud, 3)} strokeWidth={F.f2} /> </g> )} {moon && ( <g> <path d={`M0 ${h} L${moonLine.slice(1)} L${w} ${h} Z`} fill={stop(GRAPH.moon, 4)} /> <path d={moonLine} fill="none" stroke={stop(GRAPH.moonLine, 2)} strokeWidth={F.f2} /> </g> )} {moonRise && ( <g> <line x1={riseX} x2={riseX} y1={h} y2={labelY + F.f8} stroke={stop(GRAPH.moonLine, 2)} /> <circle cx={riseX} cy={labelY + F.f5} r={F.f8} fill={stop(GRAPH.rise, 3)} /> <circle cx={riseX} cy={labelY + F.f5} r={F.f3} fill={stop(GRAPH.rise)} /> <GraphPill x={riseX + F.f13} y={labelY + F.f5} text="MOON RISE" tracking={1} color={stop("deep-space-50")} fill={stop(GRAPH.moon, 4)} stroke={stop(GRAPH.moonLine, 3)} /> </g> )} {win && ( <g> <rect x={winStart} y={0} width={winEnd - winStart} height={h} fill="url(#g-prime)" /> <GraphPill x={winMid - pillWidth("PRIME VIEW", 0.5) / 2} y={F.f8 + F.f21 / 2} text="PRIME VIEW" radius={F.f21} color={stop(GRAPH.prime)} fill={stop(GRAPH.prime, 5)} stroke={stop(GRAPH.prime, 3)} /> </g> )} {now && <NowMarker x={nowX} top={nowTop} bottom={h} />} {[0, 1, 2, 3, 4].map((i) => { const m = NIGHT_START + (span * i) / 4; const midnight = Math.round(m) % 1440 === 0; return ( <text key={i} x={(w * i) / 4} y={h + F.f21} fontSize={T.s2n} fontWeight={midnight ? 700 : 400} fill={midnight ? stop(GRAPH.ink) : stop("space-grey-300")} textAnchor={i === 0 ? "start" : i === 4 ? "end" : "middle"}> {clock(m)} </text> ); })} </svg> </div> );}app-ui.css
.app-graph { position: relative; width: 100%; }.app-graph svg { display: block; width: 100%; overflow: visible; }.app-legend { display: flex; flex-wrap: wrap; gap: var(--spacing-f21); margin-top: var(--spacing-f13); }Usage
ConditionsGraph(cloudCoverData: hourly,moonCurve: curve,moonRiseTime: rise,primeViewWindow: window,startTime: start,endTime: end,)conditions_graph.dart · ConditionsGraph
class ConditionsGraph extends StatelessWidget { const ConditionsGraph({ super.key, this.themeColor = Colors.indigo, this.moonRiseTime, this.moonCurve, this.cloudCoverData, required this.startTime, required this.endTime, this.primeViewWindow, }); final Color themeColor; final DateTime? moonRiseTime; final List<GraphPoint>? moonCurve; final List<HourlyForecast>? cloudCoverData; final DateTime startTime; final DateTime endTime; final PrimeViewWindow? primeViewWindow; @override Widget build(BuildContext context) { return Stack( children: <Widget>[ // Graph Painter CustomPaint( size: Size.infinite, painter: _ConditionsGraphPainter( themeColor: themeColor, moonRiseTime: moonRiseTime, moonCurve: moonCurve, cloudCoverData: cloudCoverData, startTime: startTime, endTime: endTime, primeViewWindow: primeViewWindow, ), ), // Labels (X-Axis) Positioned( bottom: 0, left: 0, right: 0, height: 30, child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: _buildTimeLabels(), ), ), ], ); } List<Widget> _buildTimeLabels() { final List<Widget> labels = <Widget>[]; final Duration duration = endTime.difference(startTime); for (int i = 0; i <= 4; i++) { final DateTime time = startTime.add(Duration(minutes: (duration.inMinutes * (i / 4)).round())); final bool isMidnight = time.hour == 0; labels.add( Text( '${time.hour.toString().padLeft(2, '0')}:00', style: TextStyle( color: isMidnight ? Colors.white : Colors.grey, fontSize: 10, fontWeight: isMidnight ? FontWeight.bold : FontWeight.normal ), ), ); } return labels; }}graph_theme.dart · GraphTheme
class GraphTheme { static const Color moonColor = Color(0xFF4F46E5); // Indigo-600 static const Color cloudCoverColor = Colors.white24; static const Color moonFillColor = Color(0xFF1E1B4B); // Dark Indigo static const Color moonStrokeColor = Color(0xFF6366F1); // Indigo-500 static const Color objectCurveColor = Color(0xFF3B82F6); // Blue-500 static const Color objectGlowColor = Color(0xFF3B82F6); static const Color nowIndicatorColor = Color(0xFFF97316); // Orange-500 static const Color nowLabelColor = Color(0xFFFB923C); // Orange-400 static const Color primeViewColor = Color(0xFF10B981); // Emerald-500 static const Color primeViewLabelColor = Color(0xFF6EE7B7); // Emerald-300}API reference
| React prop | Flutter parameter | Type | Default | Meaning |
|---|---|---|---|---|
| layers | which of cloudCoverData, moonCurve, moonRiseTime, primeViewWindow are passed | object | all on | Each layer draws only when its data is present. |
| height | parent height | number | 200 | Includes 30 px for the time labels. |
Design tokens
Every value on this component is a system token. The last column is what the app widget uses today, so the gap to close is visible.
| Role | CSS token | Dart token | App widget today |
|---|---|---|---|
| Cloud | space-grey-50 at --mag-3 to --mag-6, stroke --mag-3 | AstrColors.spaceGrey[50], Mag.m3 | Colors.white 25% to 5% |
| Moon | deep-space-300 at --mag-2, line deep-space-200 | AstrColors.deepSpace[300], [200] | GraphTheme.moonFillColor #1E1B4B |
| Moon rise | aurora-pink-400 | AstrColors.auroraPink[400] | #A855F7 |
| Now | sodium-airglow-400, pulsing | AstrColors.sodiumAirglow[400] | GraphTheme.nowIndicatorColor #F97316 |
| Prime view | aurora-green-400 band at --mag-5 | AstrColors.auroraGreen[400] | GraphTheme.primeViewColor #10B981 |
| Grid | space-grey-50 at --mag-6 | Mag.m6 | white 5% |
| Strokes, dots | Fibonacci 2, 3, 5, 8 | AstrSpace.f2, f3, f5, f8 | 1.5, 3, 6 |
| Labels | --text-s-2 | AstrType.sNeg2 | 9 and 10 |