// Cold Spot Detector / Atmospheric Disturbance Index — the per-device // visual layer over lib/coldSpot.ts's pure classifiers. DevicesPage.tsx // owns and updates the rolling-baseline state as `reading` frames arrive // (mirrors GhostLog.tsx owning tell history and handing the *result* of // computeEvilMeter down to its gauge); this component is purely // presentational — given a device's current temperature/pressure readouts // (or null, if that sensor_type has never reported), it renders the // frost/ripple treatments, callouts, sparkline and composite gauge, and // renders nothing for a sensor that has never reported. A device with // neither sensor renders nothing at all. import { useId } from 'react' import { useTranslation } from 'react-i18next' import { disturbanceIndex, disturbanceLabel } from '../lib/coldSpot' import './ColdSpotPanel.css' export type TemperatureReadout = { value: number unit: string deviation: number | null isColdSpot: boolean severity: number } export type PressureReadout = { value: number unit: string deviation: number | null isPressureAnomaly: boolean severity: number direction: 'rise' | 'drop' } type ColdSpotPanelProps = { temperature: TemperatureReadout | null pressure: PressureReadout | null /** Recent temperature samples, oldest first — see lib/coldSpot.ts's * pushSample/SPARKLINE_SAMPLE_CAP. */ tempSparkline: readonly number[] } function clamp01(n: number): number { return Math.min(1, Math.max(0, n)) } export function ColdSpotPanel({ temperature, pressure, tempSparkline }: ColdSpotPanelProps) { if (!temperature && !pressure) return null const bothReported = temperature !== null && pressure !== null return (
{temperature && } {pressure && } {bothReported && ( )}
) } export default ColdSpotPanel // ---- temperature: frost treatment + sparkline ---- function Sparkline({ samples, width = 96, height = 22 }: { samples: readonly number[]; width?: number; height?: number }) { if (samples.length === 0) return null let points: string if (samples.length === 1) { points = `0,${height / 2} ${width},${height / 2}` } else { const min = Math.min(...samples) const max = Math.max(...samples) const range = max - min || 1 // avoid a degenerate 0-range when every sample is equal const stepX = width / (samples.length - 1) points = samples .map((v, i) => { const x = i * stepX const norm = (v - min) / range const y = height - norm * height return `${x.toFixed(2)},${y.toFixed(2)}` }) .join(' ') } return ( ) } function TemperatureReadoutRow({ readout, sparkline, }: { readout: TemperatureReadout sparkline: readonly number[] }) { const { t } = useTranslation() const { value, unit, deviation, isColdSpot } = readout return (
{isColdSpot && ( )} temperature {Number(value.toFixed(3))} {unit}
{isColdSpot && deviation !== null && (

{t('devices.dashboard.coldSpot.callout', { deviation: Math.abs(deviation).toFixed(1), unit, defaultValue: 'COLD SPOT — {{deviation}} {{unit}} below baseline', })}

)}
) } // ---- pressure: ripple treatment ---- function PressureReadoutRow({ readout }: { readout: PressureReadout }) { const { t } = useTranslation() const { value, unit, deviation, isPressureAnomaly } = readout return (
{isPressureAnomaly && ( )} pressure {Number(value.toFixed(3))} {unit}
{isPressureAnomaly && deviation !== null && (

{t('devices.dashboard.pressureAnomaly.callout', { magnitude: Math.abs(deviation).toFixed(1), unit, defaultValue: 'PRESSURE ANOMALY — {{magnitude}} {{unit}} swing, atmosphere shifting', })}

)}
) } // ---- composite: Atmospheric Disturbance Index, a crescent arc gauge ---- // (structurally the same polar-arc technique as GhostLog.tsx's evil-meter // gauge — a family resemblance for the "hacker witch" visual language — // but a single filled sweep rather than a belief band, since there's no // uncertainty-spread concept here, just one 0..100 score.) const CX = 50 const CY = 44 const R = 32 const BAND_THICKNESS = 10 function angleForValue(v: number): number { return 180 - clamp01(v) * 180 } function polar(radius: number, angleDeg: number): { x: number; y: number } { const rad = (angleDeg * Math.PI) / 180 return { x: CX + radius * Math.cos(rad), y: CY - radius * Math.sin(rad) } } function DisturbanceGauge({ index, correlated }: { index: number; correlated: boolean }) { const { t } = useTranslation() const gaugeId = useId() const label = disturbanceLabel(index, correlated) const value = clamp01(index / 100) const outerR = R + BAND_THICKNESS / 2 const innerR = R - BAND_THICKNESS / 2 const startAngle = 180 const endAngle = angleForValue(value) const outerStart = polar(outerR, startAngle) const outerEnd = polar(outerR, endAngle) const innerEnd = polar(innerR, endAngle) const innerStart = polar(innerR, startAngle) const bandPath = [ `M ${outerStart.x} ${outerStart.y}`, `A ${outerR} ${outerR} 0 0 1 ${outerEnd.x} ${outerEnd.y}`, `L ${innerEnd.x} ${innerEnd.y}`, `A ${innerR} ${innerR} 0 0 0 ${innerStart.x} ${innerStart.y}`, 'Z', ].join(' ') const trackStart = polar(R, 180) const trackEnd = polar(R, 0) const trackPath = `M ${trackStart.x} ${trackStart.y} A ${R} ${R} 0 0 1 ${trackEnd.x} ${trackEnd.y}` const bucketClass = correlated && index >= 40 ? 'converging' : index >= 70 ? 'severe' : index >= 40 ? 'rising' : index >= 15 ? 'faint' : 'calm' return (

{t('devices.dashboard.disturbance.title', { defaultValue: 'ATMOSPHERIC DISTURBANCE', })}

{index} {label}
) }