Components
Object visibility graph
Where a planet or deep sky object is over the night: its altitude above the horizon, the moon and the clouds in the way, and an optional horizon strip showing where it sits at the scrubbed time. The app has two variants.
Preview
VisibilityOBJECTMOONCLOUD
6 PM9 PM12 AM3 AM6 AM
Variants
Pick a variant with the controls. The preview and both code blocks follow.
Variant
Object
Highlight
Horizon view
VisibilityOBJECTMOONCLOUD
6 PM9 PM12 AM3 AM6 AM
Variant.tsx
<AppObjectGraph />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
<AppObjectGraph /><AppObjectGraph variant="altitude" tone="sodium-airglow" /><AppObjectGraph objectId="late" horizon />app-object-graph.tsx · AppObjectGraph
/** * The app's two object graphs. "visibility" is VisibilityGraphWidget: cloud area, the moon area, the object curve * with glow, the current position dot, NOW, moon rise, the peak dot and a scrubber with a tooltip. "altitude" is * the simpler AltitudeGraph: one curve over the night with a faint cloud backdrop, a NOW dot and a scrubber. * `horizon` adds a strip showing where the object sits above the horizon line at the scrubbed time. */export function AppObjectGraph({ variant = "visibility", objectId = "high", tone = "deep-space", horizon = false,}: { variant?: "visibility" | "altitude"; objectId?: string; tone?: Tone; horizon?: boolean;}) { const [ref, w] = useElementWidth<HTMLDivElement>(560); const [scrub, setScrub] = useState<number | null>(null); const o = OBJECTS.find((x) => x.id === objectId) ?? OBJECTS[0]; const full = variant === "visibility"; const color = stop(TONE_STOP[tone]); const plotH = full ? HEIGHT : HEIGHT - F.f34; const x = (m: number): number => ((m - NIGHT_START) / SPAN) * w; const objY = (a: number): number => plotH - (Math.max(0, a) / 90) * plotH * 0.7; const alt = (m: number): number => (full ? objectAltitude(o, m) : Math.sin(((m - NIGHT_START) / SPAN) * Math.PI) * 80); const altY = (m: number): number => (full ? objY(alt(m)) : plotH - Math.sin(((m - NIGHT_START) / SPAN) * Math.PI) * plotH * 0.8); const cloudPts: Pt[] = CLOUD_HOURLY.map((c, i) => [x(NIGHT_START + i * 60), plotH - (c / 100) * plotH] as const); const cloudArea = `M${cloudPts[0][0]} ${plotH} L${cloudPts[0][0]} ${cloudPts[0][1]}${smooth(cloudPts).replace(/^M\S+ \S+/, "")} L${cloudPts.at(-1)![0]} ${plotH} L${w} ${plotH} Z`; const objLine = "M" + TIMES.map((m) => `${x(m)} ${altY(m)}`).join(" L"); const moonLine = "M" + TIMES.map((m) => `${x(m)} ${objY(moonAltitude(m))}`).join(" L"); const peak = TIMES.reduce((best, m) => (alt(m) > alt(best) ? m : best), TIMES[0]); const nowX = x(NOW); const riseX = x(MOON_RISE_MINUTES); const mm = scrub === null ? NOW : NIGHT_START + scrub * SPAN; const riseY = plotH * 0.75; function move(e: PointerEvent<SVGSVGElement>): void { const box = e.currentTarget.getBoundingClientRect(); setScrub(Math.max(0, Math.min(1, (e.clientX - box.left) / box.width))); } return ( <div className="app-ui app-og"> {full && ( <div className="app-og-head"> <strong>Visibility</strong> <span className="app-legend" style={{ margin: 0 }}> <span><i style={{ background: legendColor("object", color) }} />OBJECT</span> <span><i style={{ background: legendColor("moon") }} />MOON</span> <span><i style={{ background: legendColor("cloud") }} />CLOUD</span> </span> </div> )} <div ref={ref} className="app-og-plot" style={{ height: HEIGHT, backgroundImage: full ? `linear-gradient(to bottom, color-mix(in srgb, ${color} var(--mag-5), transparent), transparent)` : undefined }}> <svg viewBox={`0 0 ${w} ${HEIGHT}`} height={HEIGHT} role="img" aria-label={`${o.name} visibility over the night`} onPointerMove={move} onPointerDown={move} onPointerLeave={() => setScrub(null)}> <defs> <filter id="og-curve-glow" x="-10%" y="-30%" width="120%" height="160%"><feGaussianBlur stdDeviation={F.f3} /></filter> <linearGradient id="og-cloud" x1="0" x2="0" y1="0" y2="1"> <stop offset="0" stopColor={stop(GRAPH.cloud, 3)} /> <stop offset="1" stopColor={stop(GRAPH.cloud, 6)} /> </linearGradient> <linearGradient id="og-now" gradientUnits="userSpaceOnUse" x1="0" x2="0" y1={NOW_TOP} y2={plotH}> <stop offset="0" stopColor={stop(GRAPH.now, 1)} /> <stop offset="1" stopColor={stop(GRAPH.now, 10)} /> </linearGradient> </defs> {full ? ( <> <path d={cloudArea} fill="url(#og-cloud)" /> <path d={smooth(cloudPts)} fill="none" stroke={stop(GRAPH.cloud, 3)} strokeWidth={F.f2} /> <path d={`M0 ${plotH} L${moonLine.slice(1)} L${w} ${plotH} Z`} fill={stop(GRAPH.moon, 4)} /> <path d={moonLine} fill="none" stroke={stop(GRAPH.moonLine, 2)} strokeWidth={F.f2} /> <path d={objLine} fill="none" stroke={color} strokeWidth={F.f5} filter="url(#og-curve-glow)" /> <path d={objLine} fill="none" stroke={color} strokeWidth={F.f3} strokeLinecap="round" strokeLinejoin="round" /> <g> <circle cx={nowX} cy={objY(alt(NOW))} r={F.f8} fill={color} filter="url(#og-curve-glow)" /> <circle cx={nowX} cy={objY(alt(NOW))} r={F.f5} fill={stop(GRAPH.ink)} stroke={color} strokeWidth={F.f3} /> </g> <line x1={riseX} x2={riseX} y1={plotH} y2={riseY + F.f8} stroke={stop(GRAPH.moonLine, 2)} /> <circle cx={riseX} cy={riseY + F.f5} r={F.f8} fill={stop(GRAPH.rise, 3)} /> <circle cx={riseX} cy={riseY + F.f5} r={F.f3} fill={stop(GRAPH.rise)} /> <GraphPill x={riseX + F.f13} y={riseY + F.f5} text="MOON RISE" tracking={1} color={stop("deep-space-50")} fill={stop(GRAPH.moon, 4)} stroke={stop(GRAPH.moonLine, 3)} /> <rect x={nowX - F.f2} y={NOW_TOP} width={F.f3 + F.f1} height={plotH - NOW_TOP} fill="url(#og-now)" filter="url(#og-curve-glow)" /> <rect x={nowX - F.f1 / 2} y={NOW_TOP} width={F.f1} height={plotH - NOW_TOP} fill="url(#og-now)" /> <circle className="app-pulse" cx={nowX} cy={NOW_TOP} r={F.f8} fill={stop(GRAPH.now, 2)} /> <circle cx={nowX} cy={NOW_TOP} r={F.f3} fill={stop(GRAPH.now)} /> <GraphPill x={nowX + F.f13} y={NOW_TOP} text="NOW" weight={700} color={stop(GRAPH.now)} fill={stop(GRAPH.now, 5)} stroke={stop(GRAPH.now, 3)} /> {alt(peak) > 0 && <circle cx={x(peak)} cy={altY(peak)} r={F.f5} fill={color} />} </> ) : ( <> <path d={`M0 ${plotH} L0 ${plotH * 0.2} C${w * 0.125} ${plotH * 0.2} ${w * 0.125} ${plotH * 0.7} ${w * 0.25} ${plotH * 0.7} C${w * 0.375} ${plotH * 0.7} ${w * 0.375} ${plotH * 0.9} ${w * 0.5} ${plotH * 0.9} C${w * 0.625} ${plotH * 0.9} ${w * 0.625} ${plotH * 0.4} ${w * 0.75} ${plotH * 0.4} C${w * 0.875} ${plotH * 0.4} ${w * 0.875} ${plotH * 0.1} ${w} ${plotH * 0.1} L${w} ${plotH} Z`} fill={stop(GRAPH.cloud, 6)} /> <path d={objLine} fill="none" stroke={color} strokeWidth={F.f3} strokeLinecap="round" /> <circle cx={w / 2} cy={plotH - plotH * 0.8} r={F.f5} fill={color} /> <circle cx={w * 0.2} cy={plotH - Math.sin(0.2 * Math.PI) * plotH * 0.8} r={F.f5} fill={stop(GRAPH.ink)} /> <circle cx={w * 0.2} cy={plotH - Math.sin(0.2 * Math.PI) * plotH * 0.8} r={F.f2} fill={color} /> {[0, 1, 2, 3, 4].map((i) => ( <text key={i} x={(w * i) / 4} y={HEIGHT - F.f13} fontSize={T.s2n} fontWeight={i === 2 ? 700 : 400} fill={i === 2 ? stop(GRAPH.ink) : stop("space-grey-300")} textAnchor={i === 0 ? "start" : i === 4 ? "end" : "middle"}>{clock(NIGHT_START + (SPAN * i) / 4)}</text> ))} </> )} {scrub !== null && ( <g> <line x1={scrub * w} x2={scrub * w} y1={0} y2={plotH} stroke={full ? color : stop(GRAPH.ink, 1)} /> {!full && ( <> <circle cx={scrub * w} cy={altY(mm)} r={F.f8} fill={stop(GRAPH.ink)} /> <circle cx={scrub * w} cy={altY(mm)} r={F.f5} fill={color} /> </> )} </g> )} </svg> {scrub !== null && ( <div className="app-og-tip" style={{ left: Math.max(0, Math.min(w - F.f144, scrub * w)) }}> <strong>{clock(mm)}</strong> <span style={{ color }}>Alt: {Math.max(0, alt(mm)).toFixed(1)}°</span> </div> )} </div> {full && ( <div className="app-og-axis"> {[0, 1, 2, 3, 4].map((i) => ( <span key={i}>{hour12(NIGHT_START + (SPAN * i) / 4)}</span> ))} </div> )} {horizon && <HorizonView objectId={objectId} minutes={mm} color={color} />} </div> );}app-object-graph.tsx · HorizonView
/** Where the object is on the horizon: altitude above the line, azimuth along it, east to west. */function HorizonView({ objectId, minutes, color }: { objectId: string; minutes: number; color: string }) { const [ref, w] = useElementWidth<HTMLDivElement>(560); const o = OBJECTS.find((x) => x.id === objectId) ?? OBJECTS[0]; const alt = objectAltitude(o, minutes); const az = azimuth(o.dec, minutes, o.transit); const H = F.f144; const gy = H - F.f21; const pad = F.f21; const px = (a: number): number => ((Math.min(300, Math.max(60, a)) - 60) / 240) * (w - 2 * pad) + pad; const x = px(az); const y = gy - (Math.max(0, alt) / 90) * (gy - F.f21); return ( <div ref={ref} className="app-ui app-og-horizon"> <svg viewBox={`0 0 ${w} ${H}`} height={H} role="img" aria-label={`${o.name} at ${Math.round(alt)} degrees altitude, ${compass(az)}`}> <defs> <linearGradient id="og-ground" x1="0" x2="0" y1="0" y2="1"> <stop offset="0" stopColor={stop(GRAPH.ink, 5)} /> <stop offset="1" stopColor={stop(GRAPH.ink, 10)} /> </linearGradient> <filter id="og-glow"><feGaussianBlur stdDeviation={F.f3 + F.f1} /></filter> </defs> <rect x={0} y={gy} width={w} height={H - gy} fill="url(#og-ground)" /> <line x1={0} x2={w} y1={gy} y2={gy} stroke={stop(GRAPH.ink, 3)} /> {[30, 60].map((a) => ( <line key={a} x1={0} x2={w} y1={gy - (a / 90) * (gy - F.f21)} y2={gy - (a / 90) * (gy - F.f21)} stroke={stop(GRAPH.ink, 6)} strokeDasharray={`${F.f3} ${F.f5}`} /> ))} {[["E", 90], ["S", 180], ["W", 270]].map(([l, a]) => ( <text key={l} x={px(Number(a))} y={H - F.f5} fontSize={T.s2n} fill={stop(GRAPH.ink, 2)} textAnchor="middle">{l}</text> ))} {alt > 0 && <line x1={x} x2={x} y1={y} y2={gy} stroke={color} strokeDasharray={`${F.f2} ${F.f3}`} />} {alt > 0 ? ( <g> <circle cx={x} cy={y} r={F.f13} fill={color} filter="url(#og-glow)" /> <circle cx={x} cy={y} r={F.f5} fill={stop(GRAPH.ink)} stroke={color} strokeWidth={F.f3} /> </g> ) : ( <text x={w / 2} y={gy - F.f13} fontSize={T.s1n} fill={stop(GRAPH.ink, 2)} textAnchor="middle">Below the horizon</text> )} <text x={pad} y={F.f21} fontSize={T.s2n} fill={stop(GRAPH.ink, 2)}>{clock(minutes)} · {alt > 0 ? `${alt.toFixed(0)}° ${compass(az)}` : "set"}</text> </svg> </div> );}app-object-graph.tsx · azimuth
/** Azimuth in degrees from north, through east, from latitude, declination and hour angle. */function azimuth(dec: number, minutes: number, transit: number): number { const phi = (LATITUDE * Math.PI) / 180; const d = (dec * Math.PI) / 180; const H = (0.25 * (minutes - transit) * Math.PI) / 180; const a = Math.atan2(-Math.cos(d) * Math.sin(H), Math.sin(d) * Math.cos(phi) - Math.cos(d) * Math.sin(phi) * Math.cos(H)); return ((a * 180) / Math.PI + 360) % 360;}app-ui.css
.app-og { width: 100%; }.app-og-head { display: flex; justify-content: space-between; align-items: center; padding: var(--spacing-f8) var(--spacing-f13); font-size: var(--text-s1); font-weight: 700; }.app-og-plot { position: relative; border-radius: var(--radius-f21); border: var(--spacing-f1) solid var(--hair-strong); background-color: color-mix(in srgb, var(--c-text) var(--mag-6), transparent); overflow: hidden; touch-action: pan-y; }.app-og-axis { display: flex; justify-content: space-between; padding: var(--spacing-f8) var(--spacing-f13) 0; font-size: var(--text-s-1); font-weight: 500; color: var(--c-muted); }.app-og-tip { position: absolute; top: var(--spacing-f8); padding: var(--spacing-f8); display: grid; gap: var(--spacing-f2); border-radius: var(--radius-f8); background: color-mix(in srgb, var(--c-ink) var(--mag-1), transparent); border: var(--spacing-f1) solid var(--hair-strong); font-size: var(--text-s-1); pointer-events: none; }.app-og-horizon { margin-top: var(--spacing-f13); border-radius: var(--radius-f13); border: var(--spacing-f1) solid var(--hair); background: color-mix(in srgb, var(--c-text) var(--mag-7), transparent); overflow: hidden; }.app-pulse { transform-box: fill-box; transform-origin: center; animation: app-pulse 2s ease-in-out infinite; }Usage
VisibilityGraphWidget(objectId: 'jupiter',highlightColor: AppColors.accent,)AltitudeGraph(themeColor: Colors.orange)visibility_graph_widget.dart · VisibilityGraphWidget
/// Widget that displays the visibility graph using CustomPaintclass VisibilityGraphWidget extends ConsumerStatefulWidget { const VisibilityGraphWidget({ super.key, required this.objectId, this.highlightColor, }); final String objectId; final Color? highlightColor; @override ConsumerState<VisibilityGraphWidget> createState() => _VisibilityGraphWidgetState();}visibility_graph_painter.dart · VisibilityGraphPainter
class VisibilityGraphPainter extends CustomPainter { VisibilityGraphPainter({ required this.data, required this.scrubberPosition, required this.startTime, required this.endTime, required this.currentTime, this.highlightColor, this.cloudCoverData, }) { final Color color = highlightColor ?? const Color(0xFF3B82F6); _objectCurvePaint = Paint() ..color = color ..strokeWidth = 3 ..style = PaintingStyle.stroke ..strokeCap = StrokeCap.round; _objectCurveGlowPaint = Paint() ..color = color.withValues(alpha: 0.4) ..strokeWidth = 6 ..style = PaintingStyle.stroke ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 4); _peakDotPaint = Paint()..color = color; _moonFillPaint = Paint() ..color = const Color(0xFF1E1B4B).withValues(alpha: 0.5) ..style = PaintingStyle.fill; _moonStrokePaint = Paint() ..color = const Color(0xFF6366F1).withValues(alpha: 0.5) ..style = PaintingStyle.stroke ..strokeWidth = 1.5; } final VisibilityGraphData data; final double scrubberPosition; // 0.0 to 1.0 final DateTime startTime; final DateTime endTime; final Color? highlightColor; final List<HourlyForecast>? cloudCoverData; // Cache Paint objects late final Paint _objectCurvePaint; late final Paint _objectCurveGlowPaint; late final Paint _peakDotPaint; late final Paint _moonFillPaint; late final Paint _moonStrokePaint; final DateTime currentTime; @override void paint(Canvas canvas, Size size) { // 1. Draw Cloud Cover (Background Layer) _drawCloudCover(canvas, size); // 2. Draw Moon Interference (if any) _drawMoonInterference(canvas, size); // 3. Draw Object Curve _drawObjectCurve(canvas, size); // 3.5. Draw Current Position Indicator (AC #1) _drawCurrentPositionIndicator(canvas, size); // 4. Draw Current Time Indicator _drawNowIndicator(canvas, size); // 5. Draw Moon Rise Indicator _drawMoonRiseIndicator(canvas, size); // 6. Draw Peak Altitude (Blue Circle) _drawPeakIndicator(canvas, size); // 7. Draw Scrubber (if active) if (scrubberPosition >= 0) { _drawScrubber(canvas, size); } } void _drawPeakIndicator(Canvas canvas, Size size) { if (data.objectCurve.isEmpty) return; // Find peak point GraphPoint? peakPoint; for (final GraphPoint point in data.objectCurve) { if (peakPoint == null || point.value > peakPoint.value) { peakPoint = point; } } if (peakPoint == null) return; final int totalDuration = endTime.difference(startTime).inMinutes; final int minutesFromStart = peakPoint.time.difference(startTime).inMinutes; // If peak is outside graph range, don't draw? Or clamp? // Usually we want to see it if it's within the window. if (minutesFromStart < 0 || minutesFromStart > totalDuration) return; final double x = (minutesFromStart / totalDuration) * size.width; final double y = size.height - (peakPoint.value / 90 * size.height *0.7); // Draw Peak Dot (Filled Circle, same color as line) canvas.drawCircle(Offset(x, y), 5, _peakDotPaint); } void _drawCloudCover(Canvas canvas, Size size) { if (cloudCoverData == null || cloudCoverData!.isEmpty) { // Fallback to subtle aesthetic background if no data _drawAestheticBackground(canvas, size); return; } final double width = size.width; final double height = size.height; final int totalDuration = endTime.difference(startTime).inMinutes; if (totalDuration == 0) return; final Path path = Path(); path.moveTo(0, height); const bool isFirst = true; // Filter and sort data final List<HourlyForecast> relevantData = cloudCoverData!.where((HourlyForecast d) => !d.time.isBefore(startTime.subtract(const Duration(hours: 1))) && !d.time.isAfter(endTime.add(const Duration(hours: 1))) ).toList()..sort((HourlyForecast a, HourlyForecast b) => a.time.compareTo(b.time)); if (relevantData.isEmpty) { _drawAestheticBackground(canvas, size); return; } final List<Offset> points = <Offset>[]; for (final HourlyForecast point in relevantData) { final int minutesFromStart = point.time.difference(startTime).inMinutes; final double x = (minutesFromStart / totalDuration) * width; final double y = height - (point.cloudCover / 100 * height); points.add(Offset(x, y)); } if (points.isEmpty) { _drawAestheticBackground(canvas, size); return; } path.moveTo(points.first.dx, height); path.lineTo(points.first.dx, points.first.dy); for (int i = 0; i < points.length - 1; i++) { final Offset p0 = points[max(0, i - 1)]; final Offset p1 = points[i]; final Offset p2 = points[i + 1]; final Offset p3 = points[min(points.length - 1, i + 2)]; final Offset cp1 = p1 + (p2 - p0) * 0.2; final Offset cp2 = p2 - (p3 - p1) * 0.2; path.cubicTo(cp1.dx, cp1.dy, cp2.dx, cp2.dy, p2.dx, p2.dy); } // Close path path.lineTo(points.last.dx, height); path.lineTo(width, height); path.close(); // Fill Gradient final Paint paint = Paint() ..shader = LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: <Color>[ Colors.white.withValues(alpha: 0.25), Colors.white.withValues(alpha: 0.05), ], ).createShader(Rect.fromLTWH(0, 0, width, height)); canvas.drawPath(path, paint); // Stroke final Paint strokePaint = Paint() ..color = Colors.white.withValues(alpha: 0.3) ..style = PaintingStyle.stroke ..strokeWidth = 1.5; // Re-create open path for stroke final Path strokePath = Path(); strokePath.moveTo(points.first.dx, points.first.dy); for (int i = 0; i < points.length - 1; i++) { final Offset p0 = points[max(0, i - 1)]; final Offset p1 = points[i]; final Offset p2 = points[i + 1]; final Offset p3 = points[min(points.length - 1, i + 2)]; final Offset cp1 = p1 + (p2 - p0) * 0.2; final Offset cp2 = p2 - (p3 - p1) * 0.2; strokePath.cubicTo(cp1.dx, cp1.dy, cp2.dx, cp2.dy, p2.dx, p2.dy); } canvas.drawPath(strokePath, strokePaint); } void _drawAestheticBackground(Canvas canvas, Size size) { final double width = size.width; final double height = size.height; final Path path = Path(); path.moveTo(0, height); // Aesthetic "cloud" curves final List<Offset> points = <Offset>[ const Offset(0, 0.8), const Offset(0.25, 0.3), const Offset(0.5, 0.1), const Offset(0.75, 0.6), const Offset(1, 0.9), ]; double mapY(double normalizedY) => height - (normalizedY * height); path.lineTo(0, mapY(points[0].dy)); for (int i = 0; i < points.length - 1; i++) { final Offset p1 = points[i]; final Offset p2 = points[i + 1]; final double x1 = p1.dx * width; final double y1 = mapY(p1.dy); final double x2 = p2.dx * width; final double y2 = mapY(p2.dy); final double cp1x = x1 + (x2 - x1) / 2; final double cp1y = y1; final double cp2x = x2 - (x2 - x1) / 2; final double cp2y = y2; path.cubicTo(cp1x, cp1y, cp2x, cp2y, x2, y2); } path.lineTo(width, height); path.close(); final Paint paint = Paint() ..color = Colors.white.withValues(alpha: 0.05) ..style = PaintingStyle.fill; canvas.drawPath(path, paint); // Stroke final Paint strokePaint = Paint() ..color = Colors.white.withValues(alpha: 0.1) ..style = PaintingStyle.stroke ..strokeWidth = 1.5; // Re-create open path for stroke to avoid bottom line final Path strokePath = Path(); strokePath.moveTo(0, mapY(points[0].dy)); for (int i = 0; i < points.length - 1; i++) { final Offset p1 = points[i]; final Offset p2 = points[i + 1]; final double x1 = p1.dx * width; final double y1 = mapY(p1.dy); final double x2 = p2.dx * width; final double y2 = mapY(p2.dy); final double cp1x = x1 + (x2 - x1) / 2; final double cp1y = y1; final double cp2x = x2 - (x2 - x1) / 2; final double cp2y = y2; strokePath.cubicTo(cp1x, cp1y, cp2x, cp2y, x2, y2); } canvas.drawPath(strokePath, strokePaint); } void _drawMoonInterference(Canvas canvas, Size size) { if (data.moonCurve.isEmpty) return; final Path path = Path(); final int totalDuration = endTime.difference(startTime).inMinutes; final Path moonPath = Path(); moonPath.moveTo(0, size.height); bool isFirst = true; bool isMoonUp = false; double firstMoonUpX = -1; for (final GraphPoint point in data.moonCurve) { final int minutesFromStart = point.time.difference(startTime).inMinutes; final double x = (minutesFromStart / totalDuration) * size.width; // Scale moon altitude to be visible but background final double y = size.height - (point.value / 90 * size.height * 0.7); if (point.value > 0) { if (!isMoonUp) { firstMoonUpX = x; isMoonUp = true; } } if (isFirst) { moonPath.lineTo(x, y); isFirst = false; } else { moonPath.lineTo(x, y); } } moonPath.lineTo(size.width, size.height); moonPath.close(); canvas.drawPath(moonPath, _moonFillPaint); // Re-create open path for stroke final Path strokePath = Path(); isFirst = true; isMoonUp = false; for (final GraphPoint point in data.moonCurve) { final int minutesFromStart = point.time.difference(startTime).inMinutes; final double x = (minutesFromStart / totalDuration) * size.width; final double y = size.height - (point.value / 90 * size.height * 0.7); if (point.value > 0) { if (!isMoonUp) { isMoonUp = true; } } if (isFirst) { strokePath.moveTo(x, y); isFirst = false; } else { strokePath.lineTo(x, y); } } canvas.drawPath(strokePath, _moonStrokePaint); } void _drawObjectCurve(Canvas canvas, Size size) { if (data.objectCurve.isEmpty) return; final Path path = Path(); final int totalDuration = endTime.difference(startTime).inMinutes; bool isFirst = true; for (final GraphPoint point in data.objectCurve) { final int minutesFromStart = point.time.difference(startTime).inMinutes; final double x = (minutesFromStart / totalDuration) * size.width; final double y = size.height - (point.value / 90 * size.height *0.7); if (isFirst) { path.moveTo(x, y); isFirst = false; } else { path.lineTo(x, y); } } canvas.drawPath(path, _objectCurveGlowPaint); canvas.drawPath(path, _objectCurvePaint); } void _drawCurrentPositionIndicator(Canvas canvas, Size size) { final DateTime now = currentTime; if (now.isBefore(startTime) || now.isAfter(endTime)) return; if (data.objectCurve.isEmpty) return; // Find surrounding points for interpolation GraphPoint? p1; GraphPoint? p2; for (int i = 0; i < data.objectCurve.length - 1; i++) { if (data.objectCurve[i].time.isBefore(now) && data.objectCurve[i+1].time.isAfter(now)) { p1 = data.objectCurve[i]; p2 = data.objectCurve[i+1]; break; } } // Handle edge case where now matches a point exactly or is first/last if (p1 == null) { // Check if it matches exactly try { final GraphPoint exact = data.objectCurve.firstWhere((GraphPoint p) => p.time.isAtSameMomentAs(now)); p1 = exact; p2 = exact; } catch (e) { return; // Should be covered by range check, but safety first } } // Interpolate Altitude final int totalMillis = p2!.time.difference(p1.time).inMilliseconds; final int nowMillis = now.difference(p1.time).inMilliseconds; final double fraction = totalMillis == 0 ? 0.0 : nowMillis / totalMillis; final double altitude = p1.value + (p2.value - p1.value) * fraction; // Calculate Coordinates final int totalDuration = endTime.difference(startTime).inMinutes; final int minutesFromStart = now.difference(startTime).inMinutes; final double x = (minutesFromStart / totalDuration) * size.width; final double y = size.height - (altitude / 90 * size.height * 0.7); // Draw Indicator (White Circle with Colored Stroke) // AC #4: Glass UI aesthetic (4px stroke width, white fill) final Color strokeColor = highlightColor ?? const Color(0xFF3B82F6); // Outer Glow canvas.drawCircle( Offset(x, y), 8, Paint()..color = strokeColor.withValues(alpha: 0.3)..maskFilter = const MaskFilter.blur(BlurStyle.normal, 4), ); // Stroke canvas.drawCircle( Offset(x, y), 5, // Radius Paint() ..color = strokeColor ..style = PaintingStyle.stroke ..strokeWidth = 3, ); // Fill canvas.drawCircle( Offset(x, y), 5, // Radius matches stroke radius (stroke is centered) Paint()..color = Colors.white, ); } void _drawNowIndicator(Canvas canvas, Size size) { final DateTime now = currentTime; if (now.isBefore(startTime) || now.isAfter(endTime)) return; final int totalDuration = endTime.difference(startTime).inMinutes; final int minutesFromStart = now.difference(startTime).inMinutes; final double x = (minutesFromStart / totalDuration) * size.width; // Height constraint: Stay below SQM badge (approx 40px from top) // Let's start the line from top + 80 (Lowered as requested) const double topY = 80; final double height = size.height; // Gradient Line final Paint nowLinePaint = Paint() ..shader = LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: <Color>[ const Color(0xFFF97316).withValues(alpha: 0.5), // Orange-500/50 const Color(0xFFF97316).withValues(alpha: 0), // Transparent ], ).createShader(Rect.fromLTWH(x, topY, 1, height - topY)); canvas.drawLine( Offset(x, topY), Offset(x, height), nowLinePaint, ); // Now Label with Dot next to it _drawNowLabel(canvas, x, topY); } void _drawScrubber(Canvas canvas, Size size) { final double x = scrubberPosition * size.width; final Color color = highlightColor ?? const Color(0xFFF97316); canvas.drawLine( Offset(x, 0), Offset(x, size.height), Paint()..color = color.withValues(alpha: 0.8)..strokeWidth = 1 ); } void _drawMoonRiseIndicator(Canvas canvas, Size size) { if (data.moonCurve.isEmpty) return; DateTime? riseTime; for (int i = 0; i < data.moonCurve.length - 1; i++) { final GraphPoint p1 = data.moonCurve[i]; final GraphPoint p2 = data.moonCurve[i+1]; if (p1.value <= 0 && p2.value > 0) { final double totalDiff = p2.value - p1.value; final double fraction = (0 - p1.value) / totalDiff; final int timeDiff = p2.time.difference(p1.time).inMilliseconds; riseTime = p1.time.add(Duration(milliseconds: (timeDiff * fraction).round())); break; } } if (riseTime == null) return; if (riseTime.isBefore(startTime) || riseTime.isAfter(endTime)) return; final int totalDuration = endTime.difference(startTime).inMinutes; final int minutesFromStart = riseTime.difference(startTime).inMinutes; final double x = (minutesFromStart / totalDuration) * size.width; final double height = size.height; final double labelY = height * 0.75; // Vertical Line (Short, up to label) final Paint linePaint = Paint() ..color = const Color(0xFF6366F1).withValues(alpha: 0.5) // Indigo-500 ..strokeWidth = 1; canvas.drawLine( Offset(x, height), Offset(x, labelY + 10), linePaint, ); // Shiny Purple Dot canvas.drawCircle( Offset(x, labelY + 5), 3, Paint()..color = const Color(0xFFA855F7), // Purple-500 ); canvas.drawCircle( Offset(x, labelY + 5), 6, Paint()..color = const Color(0xFFA855F7).withValues(alpha: 0.3), // Glow ); // Moon Label (No Icon) _drawMoonLabel(canvas, x + 8, labelY); } void _drawMoonLabel(Canvas canvas, double x, double y) { final TextPainter textPainter = TextPainter( text: const TextSpan( text: 'MOON RISE', style: TextStyle( color: Color(0xFFA5B4FC), // Indigo-300 fontSize: 9, fontWeight: FontWeight.w600, letterSpacing: 1.5, ), ), textDirection: TextDirection.ltr, ); textPainter.layout(); const double padding = 4; final Rect rect = Rect.fromLTWH(x, y, textPainter.width + padding * 2, textPainter.height + padding); final Paint bgPaint = Paint() ..color = const Color(0xFF312E81).withValues(alpha: 0.5) // Indigo-900/50 ..style = PaintingStyle.fill; final Paint borderPaint = Paint() ..color = const Color(0xFF6366F1).withValues(alpha: 0.3) // Indigo-500/30 ..style = PaintingStyle.stroke ..strokeWidth = 1; canvas.drawRRect(RRect.fromRectAndRadius(rect, const Radius.circular(4)), bgPaint); canvas.drawRRect(RRect.fromRectAndRadius(rect, const Radius.circular(4)), borderPaint); textPainter.paint(canvas, Offset(x + padding, y + padding / 2)); } void _drawNowLabel(Canvas canvas, double x, double y) { final TextPainter textPainter = TextPainter( text: const TextSpan( text: 'NOW', style: TextStyle( color: Color(0xFFFB923C), // Orange-400 fontSize: 9, fontWeight: FontWeight.bold, ), ), textDirection: TextDirection.ltr, ); textPainter.layout(); const double padding = 4; // Position label to the right of the line final Rect rect = Rect.fromLTWH(x + 6, y, textPainter.width + padding * 2, textPainter.height + padding); final Paint bgPaint = Paint() ..color = const Color(0xFFF97316).withValues(alpha: 0.1) ..style = PaintingStyle.fill; final Paint borderPaint = Paint() ..color = const Color(0xFFF97316).withValues(alpha: 0.2) ..style = PaintingStyle.stroke ..strokeWidth = 1; canvas.drawRRect(RRect.fromRectAndRadius(rect, const Radius.circular(4)), bgPaint); canvas.drawRRect(RRect.fromRectAndRadius(rect, const Radius.circular(4)), borderPaint); textPainter.paint(canvas, Offset(x + 6 + padding, y + padding / 2)); // Dot at the top of the line (next to label) canvas.drawCircle(Offset(x, y), 3, Paint()..color = const Color(0xFFFB923C)); } @override bool shouldRepaint(covariant VisibilityGraphPainter oldDelegate) { return oldDelegate.data != data || oldDelegate.scrubberPosition != scrubberPosition || oldDelegate.startTime != startTime || oldDelegate.endTime != endTime || oldDelegate.currentTime != currentTime; }}altitude_graph.dart · AltitudeGraph
class AltitudeGraph extends StatefulWidget { const AltitudeGraph({ super.key, this.themeColor = Colors.orange, }); final Color themeColor; @override State<AltitudeGraph> createState() => _AltitudeGraphState();}altitude_graph.dart · _AltitudeGraphPainter
class _AltitudeGraphPainter extends CustomPainter { _AltitudeGraphPainter({ required this.themeColor, required this.scrubberPosition, }) { _curvePaint = Paint() ..color = themeColor ..style = PaintingStyle.stroke ..strokeWidth = 3.0 ..strokeCap = StrokeCap.round; _peakDotPaint = Paint()..color = themeColor; _nowOuterDotPaint = Paint()..color = Colors.white; _nowInnerDotPaint = Paint()..color = themeColor; _scrubberLinePaint = Paint() ..color = Colors.white.withValues(alpha: 0.8) ..strokeWidth = 1; _scrubberOuterDotPaint = Paint()..color = Colors.white; _scrubberInnerDotPaint = Paint()..color = themeColor; _cloudBgPaint = Paint() ..color = Colors.white.withValues(alpha: 0.05) ..style = PaintingStyle.fill; } final Color themeColor; final double scrubberPosition; // Cache Paint objects late final Paint _curvePaint; late final Paint _peakDotPaint; late final Paint _nowOuterDotPaint; late final Paint _nowInnerDotPaint; late final Paint _scrubberLinePaint; late final Paint _scrubberOuterDotPaint; late final Paint _scrubberInnerDotPaint; late final Paint _cloudBgPaint; @override void paint(Canvas canvas, Size size) { final double width = size.width; final double height = size.height - 30; // 1. Draw Cloud Cover Background _drawCloudBackground(canvas, width, height); // 2. Draw Altitude Parabola final Path path = Path(); path.moveTo(0, height); for (double x = 0; x <= width; x++) { final double t = x / width; final double yNormalized = sin(t * pi); // 0 -> 1 -> 0 final double y = height - (yNormalized * height * 0.8); if (x == 0) { path.moveTo(x, y); } else { path.lineTo(x, y); } } canvas.drawPath(path, _curvePaint); // Draw Peak Dot final double peakX = width * 0.5; final double peakY = height - (height * 0.8); canvas.drawCircle(Offset(peakX, peakY), 5, _peakDotPaint); // Draw Current Time Indicator (Static "Now") // Let's say "Now" is at 0.2 final double nowX = width * 0.2; const double nowT = 0.2; final double nowYNormalized = sin(nowT * pi); final double nowY = height - (nowYNormalized * height * 0.8); canvas.drawCircle(Offset(nowX, nowY), 4, _nowOuterDotPaint); canvas.drawCircle(Offset(nowX, nowY), 2, _nowInnerDotPaint); // Draw Scrubber Line if (scrubberPosition >= 0) { final double x = scrubberPosition * width; canvas.drawLine( Offset(x, 0), Offset(x, height), _scrubberLinePaint, ); // Draw Dot on Curve at Scrubber final double scrubYNormalized = sin(scrubberPosition * pi); final double scrubY = height - (scrubYNormalized * height * 0.8); canvas.drawCircle(Offset(x, scrubY), 6, _scrubberOuterDotPaint); canvas.drawCircle(Offset(x, scrubY), 4, _scrubberInnerDotPaint); } } void _drawCloudBackground(Canvas canvas, double width, double height) { final Path path = Path(); path.moveTo(0, height); final List<Offset> points = <Offset>[ const Offset(0, 0.8), const Offset(0.25, 0.3), const Offset(0.5, 0.1), const Offset(0.75, 0.6), const Offset(1, 0.9), ]; double mapY(double normalizedY) => height - (normalizedY * height); path.lineTo(0, mapY(points[0].dy)); for (int i = 0; i < points.length - 1; i++) { final Offset p1 = points[i]; final Offset p2 = points[i + 1]; final double x1 = p1.dx * width; final double y1 = mapY(p1.dy); final double x2 = p2.dx * width; final double y2 = mapY(p2.dy); final double cp1x = x1 + (x2 - x1) / 2; final double cp1y = y1; final double cp2x = x2 - (x2 - x1) / 2; final double cp2y = y2; path.cubicTo(cp1x, cp1y, cp2x, cp2y, x2, y2); } path.lineTo(width, height); path.close(); canvas.drawPath(path, _cloudBgPaint); } @override bool shouldRepaint(covariant _AltitudeGraphPainter oldDelegate) { return oldDelegate.scrubberPosition != scrubberPosition || oldDelegate.themeColor != themeColor; }}API reference
| React prop | Flutter parameter | Type | Default | Meaning |
|---|---|---|---|---|
| variant | VisibilityGraphWidget or AltitudeGraph | visibility, altitude | visibility | The full graph with moon and cloud, or the single curve. |
| objectId | objectId | string | high | Which object. The site has three sample objects. |
| tone | highlightColor, themeColor | deep-space, aurora-green, aurora-pink, sodium-airglow, oxygen-airglow | deep-space | A system colour stop for the curve, peak dot, scrubber and tooltip. |
| horizon | none (proposed) | boolean | false | The strip under the graph: altitude above the horizon line and azimuth, east to west. |
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 |
|---|---|---|---|
| Plot | space-grey-50 at --mag-6, --radius-f21 | Mag.m6, AstrRadius.f21 | white 5%, 24 |
| Object curve | tone stop, --spacing-f3, glow --spacing-f5 | AstrTone.color | highlightColor #3B82F6 |
| Moon | deep-space-300 at --mag-2, line deep-space-200 | AstrColors.deepSpace[300], [200] | #1E1B4B, #6366F1 |
| Now | sodium-airglow-400, starts --spacing-f89 − f8 from the top | AstrColors.sodiumAirglow[400] | #F97316, topY 80 |
| Tooltip | --radius-f8, --spacing-f8, --text-s-1 | AstrRadius.f8, AstrSpace.f8 | 8, 8, 12 |
| Hour axis | --text-s-1, space-grey-300 | AstrType.sNeg1 | 12, white 30% |