From a209dfb47f9d382a5c2d67f48589e5b17ba270ed Mon Sep 17 00:00:00 2001 From: mrramam Date: Thu, 8 Oct 2026 13:19:49 -0700 Subject: [PATCH] feat: flyby orbits (manifest version 5); weather widget keeps its saved place Orbits. With `path: orbit`, every picture in a flyby layer circles one focus, drawn once per page load, and they all go the same way round. Each gets its own ellipse through a point within `span` and moves quicker nearer the focus (equal areas in equal times), so a theme's planets read as one system. New options: focusX, focusY, eccentricity, direction. Themes that use them declare manifestVersion 5 (4 is weather scenes, #247); older cores refuse them plainly, as for 2 to 4. Weather. Before the tabs loaded, the widget could not tell "no place saved" from "not loaded yet", so it showed the default (14818), fetched it, then saved the default's coordinates over the tab's own place. Any slow or failed first load of the tabs did it, such as a laptop waking. The widget now waits for the tabs and writes coordinates back only for a place someone chose (utils/weatherTabSettings.js). --- client/src/components/WeatherWidget.jsx | 53 ++++++-------- client/src/themes/engine/layers/flyby.jsx | 22 +++--- client/src/themes/engine/paths.js | 62 +++++++++++++++- client/src/themes/engine/paths.test.js | 78 ++++++++++++++++++++- client/src/themes/engine/schemas.js | 10 ++- client/src/utils/ornaments.test.js | 13 +++- client/src/utils/themes.js | 14 ++-- client/src/utils/themes.test.js | 2 +- client/src/utils/weatherTabSettings.js | 55 +++++++++++++++ client/src/utils/weatherTabSettings.test.js | 40 +++++++++++ docs/architecture/theme-architecture.md | 2 + docs/guides/theme-development.md | 17 ++++- server/services/themeStore.js | 2 +- 13 files changed, 317 insertions(+), 53 deletions(-) create mode 100644 client/src/utils/weatherTabSettings.js create mode 100644 client/src/utils/weatherTabSettings.test.js diff --git a/client/src/components/WeatherWidget.jsx b/client/src/components/WeatherWidget.jsx index c36e900a..bdde14de 100644 --- a/client/src/components/WeatherWidget.jsx +++ b/client/src/components/WeatherWidget.jsx @@ -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, @@ -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']); @@ -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 @@ -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; @@ -153,6 +144,7 @@ const WeatherWidget = ({ layoutMode: VALID_LAYOUT_MODES.has(weatherEntry.layoutMode) ? weatherEntry.layoutMode : 'auto', coordinates, resolvedName: weatherEntry.resolvedName || '', + chosen: hasChosenLocation(weatherEntry), }; }; @@ -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), }; } @@ -363,6 +356,7 @@ const WeatherWidget = ({ setTempUnit(savedTempUnit); setCoordinates(savedCoordinates); setLayoutMode(savedLayoutMode); + locationChosen.current = !!resolvedSettings?.chosen; setDraftLocationQuery(savedLocationQuery); setDraftTempUnit(savedTempUnit); setDraftLayoutMode(savedLayoutMode); @@ -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 @@ -436,6 +434,7 @@ const WeatherWidget = ({ tempUnit, layoutMode: layoutMode || 'auto', coordinates: isValidCoordinates(coordinates) ? coordinates : null, + chosen: locationChosen.current, }); } @@ -452,6 +451,7 @@ const WeatherWidget = ({ tempUnit: weatherSettings.tempUnit, layoutMode: weatherSettings.layoutMode || 'auto', coordinates: isValidCoordinates(weatherSettings.coordinates) ? weatherSettings.coordinates : null, + chosen: weatherSettings.chosen, }); } @@ -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. @@ -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, @@ -609,6 +601,7 @@ const WeatherWidget = ({ setTempUnit(normalizedTempUnit); setCoordinates(resolvedCoordinates); setLayoutMode(normalizedLayoutMode); + locationChosen.current = true; if (shouldRefreshForDataChange) { setShouldFetchNow(true); diff --git a/client/src/themes/engine/layers/flyby.jsx b/client/src/themes/engine/layers/flyby.jsx index 0c741083..ae8c13ae 100644 --- a/client/src/themes/engine/layers/flyby.jsx +++ b/client/src/themes/engine/layers/flyby.jsx @@ -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 @@ -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); @@ -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), @@ -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; @@ -96,7 +98,7 @@ function Traveler({ options, assets, random, mode }) { return ( setPass(nextPass(random, options, mode))} + onAnimationEnd={() => setPass(nextPass(random, options, mode, false, system))} sx={{ position: 'absolute', left: 0, @@ -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) => ( - + )); } diff --git a/client/src/themes/engine/paths.js b/client/src/themes/engine/paths.js index 770d831c..eb4c41c0 100644 --- a/client/src/themes/engine/paths.js +++ b/client/src/themes/engine/paths.js @@ -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'; @@ -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); diff --git a/client/src/themes/engine/paths.test.js b/client/src/themes/engine/paths.test.js index cd831b91..45c45047 100644 --- a/client/src/themes/engine/paths.test.js +++ b/client/src/themes/engine/paths.test.js @@ -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); @@ -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)); + } + }); + }); }); diff --git a/client/src/themes/engine/schemas.js b/client/src/themes/engine/schemas.js index b0209274..ae1c6275 100644 --- a/client/src/themes/engine/schemas.js +++ b/client/src/themes/engine/schemas.js @@ -98,7 +98,7 @@ export const LAYER_SCHEMAS = { spin: range(0, 1440), opacity: num(0, 1), // Curved crossings (manifest version 3); see paths.js. - path: { type: 'enum', values: ['line', 'arc', 'wander'] }, + path: { type: 'enum', values: ['line', 'arc', 'wander', 'orbit'] }, bend: range(0, 60), wander: range(0, 30), facing: { type: 'enum', values: ['path', 'fixed'] }, @@ -109,6 +109,11 @@ export const LAYER_SCHEMAS = { begin: { type: 'enum', values: ['waiting', 'underway'] }, hue: range(0, 360), angle: range(0, 90), + // Orbits (manifest version 5): the shared focus, how oval, which way round. + focusX: range(-300, 400), + focusY: range(-300, 400), + eccentricity: range(0, 0.9), + direction: { type: 'enum', values: ['either', 'clockwise', 'counterclockwise'] }, }, blobs: { colors: { type: 'colors', required: true }, @@ -132,6 +137,9 @@ export const LAYER_NAMES = Object.keys(LAYER_SCHEMAS); /** The flyby options manifest version 3 added: curved crossings, several at once. */ export const FLYBY_CURVE_OPTIONS = ['path', 'bend', 'wander', 'facing', 'pitch', 'glide', 'turn', 'count', 'begin', 'hue', 'angle']; +/** The flyby options manifest version 5 added: orbits around a shared focus. */ +export const FLYBY_ORBIT_OPTIONS = ['focusX', 'focusY', 'eccentricity', 'direction']; + // A theme's own confetti (chore and prize celebrations): its colors, which of // the standard shapes, and pictures from its folder mixed in. `pictures` // heights are in px; `mix` is the share of pieces that are pictures. diff --git a/client/src/utils/ornaments.test.js b/client/src/utils/ornaments.test.js index 7b1de041..bc182605 100644 --- a/client/src/utils/ornaments.test.js +++ b/client/src/utils/ornaments.test.js @@ -87,12 +87,23 @@ describe('manifest version', () => { expect(validateThemePackage({ ...base, manifestVersion: 2, ambience: [flyby] }, { assets: files })).toEqual(['curved flybys need manifestVersion 3']); // A straight flyby is still version 1. expect(validateThemePackage({ ...base, manifestVersion: 1, ambience: [{ layer: 'flyby', pictures: [{ src: 'assets/ring.svg' }], tilt: [0, 5] }] }, { assets: files })).toEqual([]); - expect(validateThemePackage({ ...base, manifestVersion: 3, ambience: [{ ...flyby, path: 'zigzag' }] }, { assets: files })[0]).toMatch(/path must be one of line, arc, wander/); + expect(validateThemePackage({ ...base, manifestVersion: 3, ambience: [{ ...flyby, path: 'zigzag' }] }, { assets: files })[0]).toMatch(/path must be one of line, arc, wander, orbit/); const tilted = { layer: 'flyby', pictures: [{ src: 'assets/ring.svg' }], angle: [0, 35] }; expect(validateThemePackage({ ...base, manifestVersion: 3, ambience: [tilted] }, { assets: files })).toEqual([]); expect(validateThemePackage({ ...base, manifestVersion: 2, ambience: [tilted] }, { assets: files })).toEqual(['curved flybys need manifestVersion 3']); }); + it('needs version 5 for orbits', () => { + const orbit = { layer: 'flyby', pictures: [{ src: 'assets/ring.svg' }], path: 'orbit', eccentricity: [0, 0.3] }; + expect(validateThemePackage({ ...base, manifestVersion: 5, ambience: [orbit] }, { assets: files })).toEqual([]); + expect(validateThemePackage({ ...base, manifestVersion: 4, ambience: [orbit] }, { assets: files })).toEqual(['orbits need manifestVersion 5']); + // An orbit option on an arc still asks for 5: an older core would not know the option. + const arc = { layer: 'flyby', pictures: [{ src: 'assets/ring.svg' }], path: 'arc', focusY: [150, 200] }; + expect(validateThemePackage({ ...base, manifestVersion: 4, ambience: [arc] }, { assets: files })).toEqual(['orbits need manifestVersion 5']); + expect(validateThemePackage({ ...base, manifestVersion: 5, ambience: [{ ...orbit, eccentricity: [0, 1.5] }] }, { assets: files })[0]).toMatch(/eccentricity/); + expect(validateThemePackage({ ...base, manifestVersion: 5, ambience: [{ ...orbit, direction: 'sideways' }] }, { assets: files })[0]).toMatch(/direction must be one of/); + }); + it('needs version 3 for button tokens, and checks their values', () => { const buttons = { '--hg-button-bg': '#ff9900', '--hg-button-text': '#000000', '--hg-button-radius': '999px', '--hg-button-weight': '700', '--hg-button-quiet-border': '#9977aa' }; expect(validateThemePackage({ ...base, manifestVersion: 3, tokens: { dark: buttons } })).toEqual([]); diff --git a/client/src/utils/themes.js b/client/src/utils/themes.js index c2215af2..65fb7fcd 100644 --- a/client/src/utils/themes.js +++ b/client/src/utils/themes.js @@ -19,7 +19,7 @@ // // scenes that follow the weather (utils/weatherScenes.js) // } -import { FLYBY_CURVE_OPTIONS, validateAmbience, validateConfetti, validateOrnaments } from '../themes/engine/schemas.js'; +import { FLYBY_CURVE_OPTIONS, FLYBY_ORBIT_OPTIONS, validateAmbience, validateConfetti, validateOrnaments } from '../themes/engine/schemas.js'; import { WEATHER_SCENE_KEYS } from './weatherScenes.js'; // Each theme is a folder: themes//theme.json, with its own fonts/ and @@ -273,8 +273,8 @@ export function validateThemePackage(pkg, { assets } = {}) { // Each version adds things a theme may use: 2, ornaments, the meter roles // and clumped sprites; 3, the button roles and curved flyby crossings // (path, count, begin and the rest); 4, weather scenes and lightning - // flashes. A theme that uses them says so, so a core too old to draw - // them refuses it plainly. + // flashes; 5, orbits. A theme that uses them says so, so a core too old + // to draw them refuses it plainly. const tokenNames = ['all', ...MODES].flatMap((key) => Object.keys(pkg.tokens?.[key] || {})); const layers = allAmbience(pkg); const needs = (version, uses) => { @@ -295,6 +295,10 @@ export function validateThemePackage(pkg, { assets } = {}) { pkg.weather !== undefined && 'weather', layers.some((layer) => layer?.layer === 'flash') && 'flash', ]); + needs(5, [ + layers.some((layer) => layer?.layer === 'flyby' + && (layer.path === 'orbit' || FLYBY_ORBIT_OPTIONS.some((key) => layer[key] !== undefined))) && 'orbits', + ]); } if (typeof pkg.id !== 'string' || !/^[a-z0-9][a-z0-9-]{0,63}$/.test(pkg.id)) errors.push('id must be a lowercase slug (a-z, 0-9, hyphens, max 64)'); if (typeof pkg.name !== 'string' || !pkg.name.trim() || pkg.name.length > 60) errors.push('name is required'); @@ -342,10 +346,10 @@ export const DEFAULT_THEME_ID = 'classic'; * manifest (a new section, new tokens) raises it, so an older core refuses a * theme it cannot draw rather than half-applying it (theme-architecture.md * ยง5). 2: ornaments and the meter roles. 3: the button roles and curved - * flybys. 4: weather scenes and the flash layer. The server's MANIFEST_VERSION + * flybys. 4: weather scenes and the flash layer. 5: orbits. The server's MANIFEST_VERSION * (services/themeStore.js) matches; a server test holds them equal. */ -export const MANIFEST_VERSION = 4; +export const MANIFEST_VERSION = 5; /** The problem reported for a theme newer than this core; the admin page words it for people. */ export const NEEDS_NEWER_HOMEGLOW = 'needs a newer version of HomeGlow'; diff --git a/client/src/utils/themes.test.js b/client/src/utils/themes.test.js index 916949a0..d2196456 100644 --- a/client/src/utils/themes.test.js +++ b/client/src/utils/themes.test.js @@ -109,7 +109,7 @@ describe('validateThemePackage', () => { }); it('follows the plugin manifest rules for manifestVersion, author and version', () => { - expect(validateThemePackage({ id: 'probe', name: 'Probe' })).toEqual(['manifestVersion must be 1 to 4']); + expect(validateThemePackage({ id: 'probe', name: 'Probe' })).toEqual(['manifestVersion must be 1 to 5']); expect(validateThemePackage({ ...base, author: 'Jane Diver', version: '2.1.0' })).toEqual([]); expect(validateThemePackage({ ...base, author: 'x'.repeat(81) })).toHaveLength(1); expect(validateThemePackage({ ...base, author: { name: 'Jane' } })).toHaveLength(1); diff --git a/client/src/utils/weatherTabSettings.js b/client/src/utils/weatherTabSettings.js new file mode 100644 index 00000000..604a559c --- /dev/null +++ b/client/src/utils/weatherTabSettings.js @@ -0,0 +1,55 @@ +// What the weather widget may do with a tab's saved settings. +// +// The widget shows a default place until a place is saved. Two rules keep +// that default from ever replacing a saved place: +// - nothing happens until the tabs have loaded: before then the widget +// cannot tell "no place saved" from "not loaded yet"; +// - only a place someone chose is written back. The widget saves a place's +// coordinates once it has looked them up, and doing that for the default +// used to overwrite a saved place whenever the tabs loaded slowly. + +/** The tab's layout map from its config_json, or {} when there is none to read. */ +export function 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 {}; + } +} + +/** + * Whether the tabs have loaded, so settings can be read and acted on. The + * active tab's config is there once they have; a widget given every tab's + * config (the background prefetch) can also tell from that list. + */ +export function tabSettingsReady(activeTabConfigJson, allTabConfigs = []) { + return activeTabConfigJson != null || (Array.isArray(allTabConfigs) && allTabConfigs.length > 0); +} + +/** Whether a saved weather entry names a place, rather than leaving the default. */ +export function hasChosenLocation(entry) { + return !!entry && String(entry.locationQuery || entry.zipCode || '').trim() !== ''; +} + +/** + * The settings to save after looking up a place's coordinates, or null when + * nothing may be saved: the place was not chosen, or it already has them. + */ +export function coordinateBackfill(target, payload) { + if (!target?.chosen || target.coordinates) return null; + const lat = payload?.coordinates?.lat; + const lon = payload?.coordinates?.lon; + if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null; + return { + locationQuery: target.locationQuery, + tempUnit: target.tempUnit, + layoutMode: target.layoutMode || 'auto', + lat, + lon, + ...(payload.resolvedName ? { resolvedName: payload.resolvedName } : {}), + }; +} diff --git a/client/src/utils/weatherTabSettings.test.js b/client/src/utils/weatherTabSettings.test.js new file mode 100644 index 00000000..7fffe7c0 --- /dev/null +++ b/client/src/utils/weatherTabSettings.test.js @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { coordinateBackfill, hasChosenLocation, parseTabConfigJson, tabSettingsReady } from './weatherTabSettings.js'; + +describe('weather tab settings', () => { + it('waits for the tabs: no config and no tab list means not loaded yet', () => { + expect(tabSettingsReady(null, [])).toBe(false); + expect(tabSettingsReady(null, undefined)).toBe(false); + expect(tabSettingsReady('{"weather":{}}', [])).toBe(true); + // The background prefetch can be on a tab with no config of its own. + expect(tabSettingsReady(null, [{ number: 1, config_json: '{}' }])).toBe(true); + }); + + it('tells a chosen place from the default', () => { + expect(hasChosenLocation({ locationQuery: '94040' })).toBe(true); + expect(hasChosenLocation({ zipCode: '94040' })).toBe(true); // the older field + expect(hasChosenLocation({ locationQuery: ' ' })).toBe(false); + expect(hasChosenLocation({ layout_x: 8, layout_y: 0 })).toBe(false); // placed, never configured + expect(hasChosenLocation(null)).toBe(false); + }); + + it('saves looked-up coordinates only for a chosen place without them', () => { + const payload = { coordinates: { lat: 37.3855, lon: -122.088 }, resolvedName: 'Mountain View' }; + const chosen = { locationQuery: '94040', tempUnit: 'F', layoutMode: 'auto', coordinates: null, chosen: true }; + expect(coordinateBackfill(chosen, payload)).toEqual({ + locationQuery: '94040', tempUnit: 'F', layoutMode: 'auto', lat: 37.3855, lon: -122.088, resolvedName: 'Mountain View', + }); + // The bug: the default, shown before the tabs loaded, was written over a saved place. + expect(coordinateBackfill({ ...chosen, locationQuery: '14818', chosen: false }, payload)).toBeNull(); + expect(coordinateBackfill({ ...chosen, coordinates: { lat: 1, lon: 2 } }, payload)).toBeNull(); + expect(coordinateBackfill(chosen, { coordinates: null })).toBeNull(); + }); + + it('reads a tab layout map from a string or an object, and nothing from junk', () => { + expect(parseTabConfigJson('{"weather":{"locationQuery":"94040"}}')).toEqual({ weather: { locationQuery: '94040' } }); + expect(parseTabConfigJson({ weather: {} })).toEqual({ weather: {} }); + expect(parseTabConfigJson('not json')).toEqual({}); + expect(parseTabConfigJson('[1]')).toEqual({}); + expect(parseTabConfigJson(null)).toEqual({}); + }); +}); diff --git a/docs/architecture/theme-architecture.md b/docs/architecture/theme-architecture.md index a6ab676f..c8c91e2b 100644 --- a/docs/architecture/theme-architecture.md +++ b/docs/architecture/theme-architecture.md @@ -276,6 +276,8 @@ Version history: theme's), and the `flash` layer for lightning (issue #247). `resolveTheme` takes the scene to show; `app.jsx` picks it from the condition at the appearance location (`utils/weatherScenes.js`, `utils/useWeatherCondition.js`). +- **5:** orbits (`path: orbit` with `focusX`, `focusY`, `eccentricity` and + `direction`): every picture in a layer circles one focus, the same way round. ## 6. Implementation order diff --git a/docs/guides/theme-development.md b/docs/guides/theme-development.md index 32fcb185..8a2e188d 100644 --- a/docs/guides/theme-development.md +++ b/docs/guides/theme-development.md @@ -48,7 +48,7 @@ folder name must match the manifest's `id`. | Field | Required | Meaning | | --- | --- | --- | -| `manifestVersion` | yes | `1`; `2` for a theme that uses `ornaments`, the meter tokens or `clumps`; `3` for one that uses the button tokens or curved flybys; `4` for one with `weather` scenes or a `flash` layer. A HomeGlow older than the version refuses the theme. | +| `manifestVersion` | yes | `1`; `2` for a theme that uses `ornaments`, the meter tokens or `clumps`; `3` for one that uses the button tokens or curved flybys; `4` for one with `weather` scenes or a `flash` layer; `5` for one that uses orbits. A HomeGlow older than the version refuses the theme. | | `id` | yes | Lowercase slug (a-z, 0-9, hyphens), the same as the folder name. | | `name` | yes | Shown in the theme picker. | | `version`, `author`, `description` | | As in a plugin manifest. The author shows as "by ...". | @@ -146,8 +146,19 @@ A `flyby` can follow a curve instead of a straight line (manifest version 3): | `hue` | Degrees of hue shift, drawn per picture, to vary colors | | `angle` | A fixed tilt for the whole crossing, in degrees, either way: a ringed planet's ring at a slant | -Starship's planets arc slowly across the sky. A `wander` path with `turn` suits -fish: they face where they swim and sometimes turn back. +A flyby can also orbit (manifest version 5). With `path: orbit`, every picture +in the layer circles one focus, picked once per page load, and they all go the +same way round. Each gets its own ellipse through a point within `span`, and +moves quicker nearer the focus. + +| Option | What it does | +| --- | --- | +| `focusX`, `focusY` | Where the shared focus may fall, in vw and vh. Keep it off screen; the default is below the screen, so orbits arch across the sky | +| `eccentricity` | 0 (a circle) to 0.9: how oval each orbit is | +| `direction` | `clockwise`, `counterclockwise` or `either` (the default: picked once per page load) | + +Starship's planets orbit slowly across the sky. A `wander` path with `turn` +suits fish: they face where they swim and sometimes turn back. A picture can animate itself: an SVG with its own CSS animation, or an animated WebP or PNG. The engine draws it like any other picture. Keep such loops small diff --git a/server/services/themeStore.js b/server/services/themeStore.js index 0c2c1ad3..42a25987 100644 --- a/server/services/themeStore.js +++ b/server/services/themeStore.js @@ -21,7 +21,7 @@ const THEME_ID = /^[a-z0-9][a-z0-9-]{0,63}$/; // The newest theme manifest this core understands (client/src/utils/themes.js, // MANIFEST_VERSION; a test holds them equal). Newer themes are refused at // install and left out of the store's list. -const MANIFEST_VERSION = 4; +const MANIFEST_VERSION = 5; // Classic is the stylesheet itself; no package can replace it. const RESERVED_IDS = new Set(['classic']); const MAX_FILES = 64;