Files
qtalker---/frontend/src/components/ColdSpotPanel.tsx
Indiana 917cd7f89e feat: Cold Spot Detector / Atmospheric Disturbance Index on Device Bay
Real-time anomaly visualization for temperature/pressure readings on the
device-feed dashboard, folklore's two most iconic paranormal markers:
sudden cold spots and rapid barometric swings.

- lib/coldSpot.ts: pure, directly-testable rolling-baseline tracker
  (time-aware EMA, since hardware doesn't report on a fixed schedule),
  cold-spot and pressure-anomaly classifiers, and a composite
  Atmospheric Disturbance Index that rewards correlated anomalies
  (a lone signal caps at 50/100; only both deviating together can
  reach 100) — modeled on evilMeter.ts's threaded-state pattern.
- components/ColdSpotPanel.tsx/.css: frost treatment + sparkline for
  temperature, ripple treatment for pressure, and a crescent-arc
  composite gauge (GhostLog's evil-meter gauge as the visual family
  reference) that only appears once a device has reported both sensors.
- DevicesPage.tsx: owns per-device baseline state, feeds it from
  `reading` frames, falls back to the existing generic row for any
  non-numeric temperature/pressure value.

26 new coldSpot.test.ts cases (warm-up, genuine vs. fluctuation,
correlated-vs-solo index, gappy/out-of-order data) and 7 new
DevicesPage integration tests. Full suite: 302/302 passing, tsc clean,
i18n coverage clean (en/es).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 22:00:35 +00:00

261 lines
8.6 KiB
TypeScript

// 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 (
<div className="coldspot-panel" data-testid="coldspot-panel">
{temperature && <TemperatureReadoutRow readout={temperature} sparkline={tempSparkline} />}
{pressure && <PressureReadoutRow readout={pressure} />}
{bothReported && (
<DisturbanceGauge
index={disturbanceIndex(temperature!.severity, pressure!.severity)}
correlated={temperature!.isColdSpot && pressure!.isPressureAnomaly}
/>
)}
</div>
)
}
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 (
<svg
className="coldspot-sparkline"
viewBox={`0 0 ${width} ${height}`}
preserveAspectRatio="none"
aria-hidden="true"
>
<polyline points={points} className="coldspot-sparkline-line" />
</svg>
)
}
function TemperatureReadoutRow({
readout,
sparkline,
}: {
readout: TemperatureReadout
sparkline: readonly number[]
}) {
const { t } = useTranslation()
const { value, unit, deviation, isColdSpot } = readout
return (
<div className={`sensor-row coldspot-temp-row${isColdSpot ? ' is-cold-spot' : ''}`}>
<div className="coldspot-row-main">
<span className="sensor-type">
{isColdSpot && (
<span className="coldspot-frost-glyph" aria-hidden="true">
❄
</span>
)}
temperature
</span>
<span className="sensor-value flicker-soft">
{Number(value.toFixed(3))} <span className="sensor-unit">{unit}</span>
</span>
</div>
<Sparkline samples={sparkline} />
{isColdSpot && deviation !== null && (
<p className="coldspot-callout" data-testid="cold-spot-callout" role="status">
{t('devices.dashboard.coldSpot.callout', {
deviation: Math.abs(deviation).toFixed(1),
unit,
defaultValue: 'COLD SPOT — {{deviation}} {{unit}} below baseline',
})}
</p>
)}
</div>
)
}
// ---- pressure: ripple treatment ----
function PressureReadoutRow({ readout }: { readout: PressureReadout }) {
const { t } = useTranslation()
const { value, unit, deviation, isPressureAnomaly } = readout
return (
<div className={`sensor-row coldspot-pressure-row${isPressureAnomaly ? ' is-pressure-anomaly' : ''}`}>
<div className="coldspot-row-main">
<span className="sensor-type">
{isPressureAnomaly && (
<span className="coldspot-ripple-glyph" aria-hidden="true">
◎
</span>
)}
pressure
</span>
<span className="sensor-value flicker-soft">
{Number(value.toFixed(3))} <span className="sensor-unit">{unit}</span>
</span>
</div>
{isPressureAnomaly && deviation !== null && (
<p className="coldspot-callout" data-testid="pressure-anomaly-callout" role="status">
{t('devices.dashboard.pressureAnomaly.callout', {
magnitude: Math.abs(deviation).toFixed(1),
unit,
defaultValue: 'PRESSURE ANOMALY — {{magnitude}} {{unit}} swing, atmosphere shifting',
})}
</p>
)}
</div>
)
}
// ---- 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 (
<div
className={`disturbance-gauge disturbance-gauge--${bucketClass}`}
data-testid="disturbance-gauge"
>
<p className="coldspot-section-title">
{t('devices.dashboard.disturbance.title', {
defaultValue: 'ATMOSPHERIC DISTURBANCE',
})}
</p>
<svg
viewBox="0 0 100 50"
className="disturbance-gauge-svg"
role="meter"
aria-label={t('devices.dashboard.disturbance.ariaLabel', {
defaultValue: 'atmospheric disturbance index',
})}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={index}
aria-valuetext={label}
>
<defs>
<linearGradient id={gaugeId} x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" className="disturbance-gauge-stop-start" />
<stop offset="100%" className="disturbance-gauge-stop-end" />
</linearGradient>
</defs>
<path d={trackPath} className="disturbance-gauge-track" />
<path d={bandPath} fill={`url(#${gaugeId})`} className="disturbance-gauge-band" />
</svg>
<div className="disturbance-gauge-readout">
<span className="disturbance-gauge-index">{index}</span>
<span className="disturbance-gauge-label">{label}</span>
</div>
</div>
)
}