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>
This commit is contained in:
251
frontend/src/components/ColdSpotPanel.css
Normal file
251
frontend/src/components/ColdSpotPanel.css
Normal file
@@ -0,0 +1,251 @@
|
||||
/* Cold Spot Detector / Atmospheric Disturbance Index — lives inside a
|
||||
Device Bay card (DevicesPage.css owns the surrounding .device-card /
|
||||
.sensor-row chrome; this file only adds the frost/ripple/gauge layer on
|
||||
top of it). Same dark/violet --qm-* palette, with an added ice-blue for
|
||||
the cold-spot treatment — the one new hue in this feature, reserved
|
||||
entirely for "something got colder than it should have". */
|
||||
|
||||
.coldspot-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.coldspot-row-main {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.coldspot-section-title {
|
||||
margin: 0;
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
text-align: center;
|
||||
color: rgba(216, 230, 220, 0.55);
|
||||
}
|
||||
|
||||
/* ---- sparkline ---- */
|
||||
|
||||
.coldspot-sparkline {
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
display: block;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.coldspot-sparkline-line {
|
||||
fill: none;
|
||||
stroke: rgba(124, 255, 178, 0.55);
|
||||
stroke-width: 1.4;
|
||||
stroke-linejoin: round;
|
||||
stroke-linecap: round;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.is-cold-spot .coldspot-sparkline-line {
|
||||
stroke: #9fe0ff;
|
||||
filter: drop-shadow(0 0 3px rgba(159, 224, 255, 0.7));
|
||||
}
|
||||
|
||||
/* ---- cold spot: frosted-glass / crystalline treatment ---- */
|
||||
|
||||
.coldspot-temp-row {
|
||||
border-radius: 3px;
|
||||
padding: 0.4rem 0.5rem;
|
||||
margin: -0.4rem -0.5rem 0;
|
||||
transition: background 200ms ease, border-color 200ms ease, box-shadow 200ms ease;
|
||||
}
|
||||
|
||||
.coldspot-temp-row.is-cold-spot {
|
||||
background: linear-gradient(155deg, rgba(159, 224, 255, 0.14), rgba(80, 160, 255, 0.03));
|
||||
border: 1px solid rgba(159, 224, 255, 0.4);
|
||||
border-image: repeating-linear-gradient(
|
||||
118deg,
|
||||
rgba(159, 224, 255, 0.65) 0px,
|
||||
rgba(159, 224, 255, 0.65) 2px,
|
||||
rgba(159, 224, 255, 0.05) 2px,
|
||||
rgba(159, 224, 255, 0.05) 7px
|
||||
)
|
||||
1;
|
||||
box-shadow:
|
||||
inset 0 0 16px rgba(159, 224, 255, 0.18),
|
||||
0 0 12px rgba(159, 224, 255, 0.2);
|
||||
backdrop-filter: blur(0.5px) brightness(1.03);
|
||||
animation: coldspot-frost-pulse 3.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.coldspot-temp-row.is-cold-spot .sensor-value {
|
||||
color: #cdeeff;
|
||||
text-shadow: 0 0 10px rgba(159, 224, 255, 0.75);
|
||||
}
|
||||
|
||||
.coldspot-frost-glyph {
|
||||
color: #9fe0ff;
|
||||
text-shadow: 0 0 6px rgba(159, 224, 255, 0.7);
|
||||
margin-right: 0.3rem;
|
||||
}
|
||||
|
||||
@keyframes coldspot-frost-pulse {
|
||||
0%, 100% { box-shadow: inset 0 0 16px rgba(159, 224, 255, 0.18), 0 0 12px rgba(159, 224, 255, 0.2); }
|
||||
50% { box-shadow: inset 0 0 22px rgba(159, 224, 255, 0.28), 0 0 18px rgba(159, 224, 255, 0.32); }
|
||||
}
|
||||
|
||||
/* ---- pressure: expanding-ripple / distortion treatment ---- */
|
||||
|
||||
.coldspot-pressure-row {
|
||||
position: relative;
|
||||
border-radius: 3px;
|
||||
padding: 0.4rem 0.5rem;
|
||||
margin: -0.4rem -0.5rem 0;
|
||||
overflow: visible;
|
||||
transition: background 200ms ease, border-color 200ms ease;
|
||||
}
|
||||
|
||||
.coldspot-pressure-row.is-pressure-anomaly {
|
||||
background: rgba(178, 107, 255, 0.07);
|
||||
border: 1px solid rgba(178, 107, 255, 0.4);
|
||||
}
|
||||
|
||||
.coldspot-pressure-row.is-pressure-anomaly .sensor-value {
|
||||
color: #d8bcff;
|
||||
text-shadow: 0 0 10px rgba(178, 107, 255, 0.65);
|
||||
}
|
||||
|
||||
.coldspot-ripple-glyph {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
color: #b26bff;
|
||||
text-shadow: 0 0 6px rgba(178, 107, 255, 0.7);
|
||||
margin-right: 0.3rem;
|
||||
}
|
||||
|
||||
.coldspot-pressure-row.is-pressure-anomaly::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin-top: -4px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(178, 107, 255, 0.6);
|
||||
pointer-events: none;
|
||||
animation: coldspot-pressure-ripple 1.8s ease-out infinite;
|
||||
}
|
||||
|
||||
@keyframes coldspot-pressure-ripple {
|
||||
0% { transform: scale(1); opacity: 0.85; }
|
||||
100% { transform: scale(7); opacity: 0; }
|
||||
}
|
||||
|
||||
/* ---- shared callout ---- */
|
||||
|
||||
.coldspot-callout {
|
||||
margin: 0;
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.coldspot-temp-row .coldspot-callout {
|
||||
color: #9fe0ff;
|
||||
text-shadow: 0 0 6px rgba(159, 224, 255, 0.5);
|
||||
}
|
||||
|
||||
.coldspot-pressure-row .coldspot-callout {
|
||||
color: #b26bff;
|
||||
text-shadow: 0 0 6px rgba(178, 107, 255, 0.5);
|
||||
}
|
||||
|
||||
/* ---- composite: Atmospheric Disturbance Index gauge ---- */
|
||||
|
||||
.disturbance-gauge {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
padding-top: 0.4rem;
|
||||
border-top: 1px dotted rgba(124, 255, 178, 0.16);
|
||||
}
|
||||
|
||||
.disturbance-gauge-svg {
|
||||
width: 100%;
|
||||
max-width: 160px;
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.disturbance-gauge-track {
|
||||
fill: none;
|
||||
stroke: rgba(124, 255, 178, 0.12);
|
||||
stroke-width: 10;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.disturbance-gauge-band {
|
||||
filter: drop-shadow(0 0 5px rgba(124, 255, 178, 0.3));
|
||||
transition: d 220ms ease;
|
||||
}
|
||||
|
||||
/* stop colors track the same escalation bucket used for the label text */
|
||||
.disturbance-gauge-stop-start {
|
||||
stop-color: #7cffb2;
|
||||
}
|
||||
.disturbance-gauge-stop-end {
|
||||
stop-color: #7cffb2;
|
||||
}
|
||||
|
||||
.disturbance-gauge--faint .disturbance-gauge-stop-start { stop-color: #7cffb2; }
|
||||
.disturbance-gauge--faint .disturbance-gauge-stop-end { stop-color: #9fe0ff; }
|
||||
|
||||
.disturbance-gauge--rising .disturbance-gauge-stop-start { stop-color: #9fe0ff; }
|
||||
.disturbance-gauge--rising .disturbance-gauge-stop-end { stop-color: #b26bff; }
|
||||
|
||||
.disturbance-gauge--severe .disturbance-gauge-stop-start { stop-color: #b26bff; }
|
||||
.disturbance-gauge--severe .disturbance-gauge-stop-end { stop-color: #ff3b5c; }
|
||||
|
||||
.disturbance-gauge--converging .disturbance-gauge-stop-start { stop-color: #ff3b5c; }
|
||||
.disturbance-gauge--converging .disturbance-gauge-stop-end { stop-color: #ffe066; }
|
||||
.disturbance-gauge--converging .disturbance-gauge-band {
|
||||
filter: drop-shadow(0 0 9px rgba(255, 59, 92, 0.55));
|
||||
}
|
||||
.disturbance-gauge--converging {
|
||||
animation: coldspot-converging-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes coldspot-converging-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.82; }
|
||||
}
|
||||
|
||||
.disturbance-gauge-readout {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.4rem;
|
||||
margin-top: -0.3rem;
|
||||
}
|
||||
|
||||
.disturbance-gauge-index {
|
||||
font-size: 0.95rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #eafff3;
|
||||
}
|
||||
|
||||
.disturbance-gauge-label {
|
||||
font-size: 0.66rem;
|
||||
letter-spacing: 0.06em;
|
||||
font-style: italic;
|
||||
color: rgba(216, 230, 220, 0.7);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.coldspot-temp-row.is-cold-spot,
|
||||
.coldspot-pressure-row.is-pressure-anomaly::after,
|
||||
.disturbance-gauge--converging {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
260
frontend/src/components/ColdSpotPanel.tsx
Normal file
260
frontend/src/components/ColdSpotPanel.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
// 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user