Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 23 additions & 30 deletions client/src/components/WeatherWidget.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import useDataRefresh from '../hooks/useDataRefresh.js';
import {
Typography,
Expand All @@ -23,6 +23,7 @@ import { API_BASE_URL } from '../utils/apiConfig.js';
import { getDeviceApiBase } from '../utils/deviceName.js';
import { formatTime, formatWeekdayShort } from '../utils/dateUtils.js';
import { isControlHidden } from '../utils/displayControls.js';
import { coordinateBackfill, hasChosenLocation, parseTabConfigJson, tabSettingsReady } from '../utils/weatherTabSettings.js';

const DEFAULT_LOCATION_QUERY = '14818';
const VALID_LAYOUT_MODES = new Set(['auto', 'compact', 'medium', 'full']);
Expand Down Expand Up @@ -79,6 +80,9 @@ const WeatherWidget = ({
const [coordinates, setCoordinates] = useState(null);
const [layoutMode, setLayoutMode] = useState('auto');
const [settingsLoaded, setSettingsLoaded] = useState(false);
// Whether the place shown was saved by someone, not the default; only a
// chosen place is ever written back (utils/weatherTabSettings.js).
const locationChosen = useRef(false);
const [settingsModalOpen, setSettingsModalOpen] = useState(false);

// Control Limits (issue #166): the gear and the error-state button both open
Expand Down Expand Up @@ -122,19 +126,6 @@ const WeatherWidget = ({
return 'F';
};

const parseTabConfigJson = (configJson) => {
if (!configJson) return {};
if (typeof configJson === 'object' && !Array.isArray(configJson)) return configJson;
if (typeof configJson !== 'string') return {};

try {
const parsed = JSON.parse(configJson);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
} catch {
return {};
}
};

const readWeatherSettingsFromTabConfig = (configJson) => {
const layoutMap = parseTabConfigJson(configJson);
const weatherEntry = layoutMap.weather;
Expand All @@ -153,6 +144,7 @@ const WeatherWidget = ({
layoutMode: VALID_LAYOUT_MODES.has(weatherEntry.layoutMode) ? weatherEntry.layoutMode : 'auto',
coordinates,
resolvedName: weatherEntry.resolvedName || '',
chosen: hasChosenLocation(weatherEntry),
};
};

Expand All @@ -174,6 +166,7 @@ const WeatherWidget = ({
layoutMode: VALID_LAYOUT_MODES.has(override.layoutMode) ? override.layoutMode : 'auto',
coordinates: isValidCoordinates(override) ? { lat: override.lat, lon: override.lon } : null,
resolvedName: override.resolvedName || '',
chosen: hasChosenLocation(override),
};
}

Expand Down Expand Up @@ -363,6 +356,7 @@ const WeatherWidget = ({
setTempUnit(savedTempUnit);
setCoordinates(savedCoordinates);
setLayoutMode(savedLayoutMode);
locationChosen.current = !!resolvedSettings?.chosen;
setDraftLocationQuery(savedLocationQuery);
setDraftTempUnit(savedTempUnit);
setDraftLayoutMode(savedLayoutMode);
Expand Down Expand Up @@ -401,11 +395,15 @@ const WeatherWidget = ({
});
};

// Until the tabs load, "no place saved" and "not loaded yet" look the
// same; wait rather than fall back to the default and fetch it.
const tabsReady = tabSettingsReady(activeTabConfigJson, allTabConfigs);
useEffect(() => {
if (!tabsReady) return;
const tabSettings = getEffectiveWeatherSettingsForTab(activeTab, activeTabConfigJson);
setSettingsLoaded(false);
applyResolvedTabSettings(tabSettings);
}, [activeTab, activeTabConfigJson, refreshNonce]);
}, [activeTab, activeTabConfigJson, refreshNonce, tabsReady]);

// Demo mode needs no special case any more: the server picks the demo
// provider behind GET /api/weather, so the widget takes the same path it
Expand Down Expand Up @@ -436,6 +434,7 @@ const WeatherWidget = ({
tempUnit,
layoutMode: layoutMode || 'auto',
coordinates: isValidCoordinates(coordinates) ? coordinates : null,
chosen: locationChosen.current,
});
}

Expand All @@ -452,6 +451,7 @@ const WeatherWidget = ({
tempUnit: weatherSettings.tempUnit,
layoutMode: weatherSettings.layoutMode || 'auto',
coordinates: isValidCoordinates(weatherSettings.coordinates) ? weatherSettings.coordinates : null,
chosen: weatherSettings.chosen,
});
}

Expand All @@ -471,19 +471,9 @@ const WeatherWidget = ({
targetCoordinates: target.coordinates,
});

if (
Number.isFinite(target.tabNumber)
&& !isValidCoordinates(target.coordinates)
&& isValidCoordinates(payload?.coordinates)
) {
await saveInstanceSettingsToTab(target.tabNumber, {
locationQuery: target.locationQuery,
tempUnit: target.tempUnit,
layoutMode: target.layoutMode || 'auto',
lat: payload.coordinates.lat,
lon: payload.coordinates.lon,
...(payload.resolvedName ? { resolvedName: payload.resolvedName } : {}),
});
const backfill = coordinateBackfill(target, payload);
if (Number.isFinite(target.tabNumber) && backfill) {
await saveInstanceSettingsToTab(target.tabNumber, backfill);
}
} catch {
// Keep prefetch failures non-blocking.
Expand Down Expand Up @@ -529,8 +519,10 @@ const WeatherWidget = ({
applyWeatherPayloadToState(payload);
if (isValidCoordinates(payload.coordinates)) {
setCoordinates(payload.coordinates);

// Backfill coordinates for existing tabs that were saved before coordinate persistence.
}
// Backfill coordinates for tabs saved before coordinates were kept, but
// only for a place someone chose: never write the default back.
if (isValidCoordinates(payload.coordinates) && locationChosen.current) {
void persistActiveTabSettingsIfChanged({
nextLocationQuery: locationQuery,
nextTempUnit: tempUnit,
Expand Down Expand Up @@ -609,6 +601,7 @@ const WeatherWidget = ({
setTempUnit(normalizedTempUnit);
setCoordinates(resolvedCoordinates);
setLayoutMode(normalizedLayoutMode);
locationChosen.current = true;

if (shouldRefreshForDataChange) {
setShouldFetchNow(true);
Expand Down
22 changes: 13 additions & 9 deletions client/src/themes/engine/layers/flyby.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React, { useMemo, useState } from 'react';
import { Box } from '@mui/material';
import { keyframes } from '@emotion/react';
import { cross, draw, forMode, pick, pickWeighted } from '../motion.js';
import { pathKeyframes, planPath } from '../paths.js';
import { pathKeyframes, planOrbitSystem, planPath } from '../paths.js';

// Now and then, one picture crosses the screen: a ship, a passing whale, a
// drifting astronaut. Each pass picks a picture (weighted), a side to enter
Expand All @@ -14,12 +14,14 @@ import { pathKeyframes, planPath } from '../paths.js';
// (paths.js): a slice of a long parabola, or a meander that faces where it
// goes and may turn around. `count` keeps several in flight at once, each on
// its own loop, and `begin: underway` puts the first of each partway along
// its path when the page loads, so the scene does not start empty.
// its path when the page loads, so the scene does not start empty. With
// `path: orbit`, every picture in the layer circles one focus, the same way
// round, each on its own ellipse.
const screenAspect = () => (typeof window !== 'undefined' && window.innerHeight
? window.innerWidth / window.innerHeight
: 16 / 9);

function nextPass(random, options, mode, first = false) {
function nextPass(random, options, mode, first = false, system = null) {
const picture = pickWeighted(random, options.pictures);
const height = draw(random, picture.height ?? options.height ?? [5, 9]);
const width = height * (picture.aspect ?? 1);
Expand All @@ -42,8 +44,8 @@ function nextPass(random, options, mode, first = false) {
// Underway: already some way along, with no wait; otherwise the pause first.
pause: underway ? (-(0.15 + random() * 0.7) * seconds).toFixed(1) : draw(random, options.every ?? [60, 180]).toFixed(1),
};
if (options.path === 'arc' || options.path === 'wander') {
const { frames } = planPath(random, options, { width }, screenAspect());
if (options.path === 'arc' || options.path === 'wander' || options.path === 'orbit') {
const { frames } = planPath(random, options, { width }, screenAspect(), system);
return {
id: random(),
src: forMode(picture.src, mode),
Expand Down Expand Up @@ -77,8 +79,8 @@ function nextPass(random, options, mode, first = false) {
const prefersReducedMotion = () => typeof window !== 'undefined'
&& window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;

function Traveler({ options, assets, random, mode }) {
const [pass, setPass] = useState(() => nextPass(random, options, mode, true));
function Traveler({ options, assets, random, mode, system }) {
const [pass, setPass] = useState(() => nextPass(random, options, mode, true, system));
const travel = useMemo(() => (pass.frames ? keyframes`${pass.frames}` : null), [pass.frames]);
const url = assets[pass.src];
if (!url) return null;
Expand All @@ -96,7 +98,7 @@ function Traveler({ options, assets, random, mode }) {
return (
<Box
key={pass.id}
onAnimationEnd={() => setPass(nextPass(random, options, mode))}
onAnimationEnd={() => setPass(nextPass(random, options, mode, false, system))}
sx={{
position: 'absolute',
left: 0,
Expand Down Expand Up @@ -137,9 +139,11 @@ function Traveler({ options, assets, random, mode }) {
function Flyby({ options, assets, random, mode }) {
const [still] = useState(prefersReducedMotion);
const [count] = useState(() => Math.max(1, draw(random, options.count ?? 1, { integer: true })));
// One focus and one way round for the whole layer, so the planets keep company.
const [system] = useState(() => (options.path === 'orbit' ? planOrbitSystem(random, options) : null));
if (still) return null;
return Array.from({ length: count }, (_, i) => (
<Traveler key={i} options={options} assets={assets} random={random} mode={mode} />
<Traveler key={i} options={options} assets={assets} random={random} mode={mode} system={system} />
));
}

Expand Down
62 changes: 61 additions & 1 deletion client/src/themes/engine/paths.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
// off screen, so the visible part is a gentle curve (planets)
// wander a meander up and down (fish); with `turn`, some swim partway in,
// turn around and leave the way they came
// orbit a piece of an ellipse around a focus the whole layer shares, all
// going the same way round (planets): each picture its own ellipse,
// quicker nearer the focus, as Kepler has it

import { draw } from './motion.js';

Expand Down Expand Up @@ -57,15 +60,72 @@ function distanceFn(random, options) {
return (t) => raw(t) / end;
}

/**
* What every orbit in a layer shares, drawn once per page load: the focus
* (x in vw, y in vh; it belongs off screen) and the way round, 1 clockwise
* on screen and -1 counterclockwise.
*/
export function planOrbitSystem(random, options) {
const focusX = draw(random, options.focusX ?? [-40, 140]);
const focusY = draw(random, options.focusY ?? [150, 260]);
const way = options.direction === 'clockwise' ? 1
: options.direction === 'counterclockwise' ? -1
: sign(random);
return { focusX, focusY, way };
}

const ORBIT_STEP = 0.002; // radians, when finding where an orbit leaves the screen

/** One pass along an orbit: enter off screen, cross within `span`, leave off screen. */
function planOrbit(random, options, picture, aspect, system) {
// Work in vh on both axes so the ellipse is not squashed by the screen's shape.
const fx = system.focusX * aspect;
const fy = system.focusY;
const [from, to] = options.span ?? [10, 70];
// The point where this orbit crosses the screen, then the ellipse through it.
const cx = (30 + random() * 40) * aspect;
const cy = from + random() * (to - from);
const thetaC = Math.atan2(cy - fy, cx - fx);
const e = clamp(draw(random, options.eccentricity ?? [0, 0.3]), 0, 0.9);
const omega = random() * Math.PI * 2;
const p = Math.hypot(cx - fx, cy - fy) * (1 + e * Math.cos(thetaC - omega));
const radius = (theta) => p / (1 + e * Math.cos(theta - omega));
const at = (theta) => ({ X: fx + radius(theta) * Math.cos(theta), Y: fy + radius(theta) * Math.sin(theta) });
const margin = (picture.width ?? 5) * 1.2;
const offScreen = ({ X, Y }) => X < -margin || X > 100 * aspect + margin || Y < -margin || Y > 100 + margin;
// Walk out from the crossing both ways until off screen (half an orbit at most).
const reach = (direction) => {
let d = 0;
while (d < Math.PI && !offScreen(at(thetaC + direction * d))) d += ORBIT_STEP;
return d;
};
const start = thetaC - system.way * reach(-system.way);
const end = thetaC + system.way * reach(system.way);

// Equal areas in equal times: time grows with r squared per step of angle.
const thetas = Array.from({ length: SAMPLES + 1 }, (_, i) => start + ((end - start) * i) / SAMPLES);
const weights = thetas.map((theta) => radius(theta) ** 2);
const elapsed = [0];
for (let i = 1; i < thetas.length; i += 1) elapsed.push(elapsed[i - 1] + (weights[i - 1] + weights[i]) / 2);
const total = elapsed[elapsed.length - 1] || 1;
const frames = thetas.map((theta, i) => {
const { X, Y } = at(theta);
return { at: (elapsed[i] / total) * 100, x: X / aspect, y: Y, angle: 0, flip: 1 };
});
return { frames, rightward: frames[frames.length - 1].x > frames[0].x, turns: false };
}

/**
* One pass of a curved crossing.
*
* picture: { width } in vh. aspect: the screen's width over its height.
* system: for an orbit, the layer's shared planOrbitSystem().
* Returns { frames: [{ at, x, y, angle, flip }], rightward }, `at` in percent
* of the pass, `flip` 1 facing right and -1 mirrored (it passes through 0
* during a turn-around).
*/
export function planPath(random, options, picture, aspect = 16 / 9) {
export function planPath(random, options, picture, aspect = 16 / 9, system = null) {
if (options.path === 'orbit') return planOrbit(random, options, picture, aspect, system ?? planOrbitSystem(random, options));
const path = options.path === 'arc' ? 'arc' : 'wander';
const facing = options.facing ?? (path === 'arc' ? 'fixed' : 'path');
const pitch = draw(random, options.pitch ?? 15);
Expand Down
78 changes: 77 additions & 1 deletion client/src/themes/engine/paths.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { seededRandom } from './motion.js';
import { pathKeyframes, planPath } from './paths.js';
import { pathKeyframes, planOrbitSystem, planPath } from './paths.js';

const plans = (options, n = 40, width = 10) => Array.from({ length: n }, (_, i) => planPath(seededRandom(i + 1), options, { width }, 16 / 9));
const visible = (frames) => frames.filter((f) => f.x >= 0 && f.x <= 100);
Expand Down Expand Up @@ -61,4 +61,80 @@ describe('curved crossings', () => {
expect(css).toMatch(/^0\.00% \{ transform: translate\(-?[\d.]+vw, -?[\d.]+vh\) rotate\(-?[\d.]+deg\) scaleX\(-?[\d.]+\); \}/);
expect(css).not.toMatch(/NaN|Infinity/);
});

describe('orbits', () => {
const aspect = 16 / 9;
const options = { path: 'orbit', span: [10, 55], eccentricity: [0, 0.35] };
// Several planets of one layer: one shared system, each its own pass.
const layer = (seed, extra = {}) => {
const random = seededRandom(seed);
const system = planOrbitSystem(random, { ...options, ...extra });
return { system, passes: Array.from({ length: 6 }, () => planPath(random, { ...options, ...extra }, { width: 10 }, aspect, system)) };
};
// Angle about the focus, in vh on both axes, as the planner works.
const angleAbout = (system, f) => Math.atan2(f.y - system.focusY, f.x * aspect - system.focusX * aspect);

it('every pass enters and leaves off screen, crossing it within `span`', () => {
for (let seed = 1; seed <= 30; seed += 1) {
for (const { frames } of layer(seed).passes) {
const off = (f) => f.x < 0 || f.x > 100 || f.y < 0 || f.y > 100;
expect(off(frames[0])).toBe(true);
expect(off(frames[frames.length - 1])).toBe(true);
expect(frames.some((f) => f.x > 25 && f.x < 75 && f.y >= 10 - 1e-6 && f.y <= 55 + 1e-6)).toBe(true);
frames.forEach((f) => { expect(f.angle).toBe(0); expect(f.flip).toBe(1); });
}
}
});

it('all the planets of a layer go the same way round the one focus', () => {
const ways = new Set();
for (let seed = 1; seed <= 30; seed += 1) {
const { system, passes } = layer(seed);
ways.add(system.way);
for (const { frames } of passes) {
// Unwrapped angle about the focus moves steadily in the layer's direction.
let previous = angleAbout(system, frames[0]);
for (const f of frames.slice(1)) {
let step = angleAbout(system, f) - previous;
if (step > Math.PI) step -= 2 * Math.PI;
if (step < -Math.PI) step += 2 * Math.PI;
expect(Math.sign(step)).toBe(system.way);
previous += step;
}
}
}
expect([...ways].sort()).toEqual([-1, 1]); // either, across page loads
expect(layer(3, { direction: 'clockwise' }).system.way).toBe(1);
expect(layer(3, { direction: 'counterclockwise' }).system.way).toBe(-1);
});

it('each planet is on its own ellipse with the focus at a focus', () => {
const { system, passes } = layer(7);
const fit = (frames) => {
// r(theta) = p / (1 + e cos(theta - omega)) means 1/r is linear in cos(theta) and sin(theta).
const rows = frames.map((f) => { const X = f.x * aspect - system.focusX * aspect; const Y = f.y - system.focusY; return [1, Math.cos(Math.atan2(Y, X)), Math.sin(Math.atan2(Y, X)), 1 / Math.hypot(X, Y)]; });
const [a, b, c] = [0, 1, 2].map((k) => rows.reduce((sum, r) => sum + r[k] * r[3], 0));
return rows.every((r) => Number.isFinite(r[3])) && [a, b, c];
};
const shapes = passes.map(({ frames }) => JSON.stringify(fit(frames).map((n) => n.toFixed(3))));
expect(new Set(shapes).size).toBe(passes.length);
});

it('moves quicker nearer the focus (equal areas in equal times)', () => {
const { system, passes } = layer(11, { eccentricity: [0.6, 0.6] });
for (const { frames } of passes) {
const r = (f) => Math.hypot(f.x * aspect - system.focusX * aspect, f.y - system.focusY);
// Angular speed times r squared is the same all along.
const swept = frames.slice(1).map((f, i) => {
let step = angleAbout(system, f) - angleAbout(system, frames[i]);
if (step > Math.PI) step -= 2 * Math.PI;
if (step < -Math.PI) step += 2 * Math.PI;
const rr = (r(f) + r(frames[i])) / 2;
return (Math.abs(step) * rr * rr) / (f.at - frames[i].at);
});
const mean = swept.reduce((a, b) => a + b, 0) / swept.length;
swept.forEach((v) => expect(Math.abs(v - mean) / mean).toBeLessThan(0.02));
}
});
});
});
Loading
Loading