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
10 changes: 8 additions & 2 deletions .github/workflows/t27-world-scan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,14 @@ jobs:
git commit -q -m "t27 worlds: refresh the catalog ($N)"
BRANCH=bot/t27-world-scan
# The bot's own branch, and only ever this job writes it -- so the
# lease is against the copy this run started from, not against a human.
git push -q --force-with-lease origin "HEAD:refs/heads/$BRANCH"
# lease is against the branch as this run found it, not against a human.
# The checkout fetched main alone, so a bare --force-with-lease has no
# tracking ref to compare with and expects the branch to be absent: once
# the first refresh had created it, every push was refused as "stale
# info" (the nights of 2026-09-17..20). Name the expected value
# explicitly; an empty one still means "must not exist yet".
EXPECT=$(git ls-remote --heads origin "refs/heads/$BRANCH" | cut -f1)
git push -q --force-with-lease="refs/heads/$BRANCH:$EXPECT" origin "HEAD:refs/heads/$BRANCH"
SHA=$(git rev-parse HEAD)
node scripts/catalog-work-report.mjs --head-sha "$SHA" --out "$RUNNER_TEMP/catalog-report.md"
PR=$(gh pr list --head "$BRANCH" --base main --state open --json number --jq '.[0].number // empty')
Expand Down
8 changes: 8 additions & 0 deletions .github/workflows/website-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,14 @@ jobs:
# stale round called stale. Pure, no browser.
- name: Queen idle reason
run: npm run check:queen-idle
# The map's camera: a close-up of every cell stays readable at three
# viewports, and also on a field eight times wider, because zoom is
# relative to the whole field and the catalogue keeps growing. A fixed
# ceiling of 128 broke this once the scan found an eleventh repository,
# and the nightly t27 world scan -- the only place these ran -- then
# failed for three nights. Pure, no browser.
- name: Queen map close-ups stay readable
run: npm run check:queen-displays && npm run check:queen-continuous
# The PASSPORT argues a result is worthless without the record of how it
# was taken, so its own figures must not drift from its own prose: every
# plotted quantity carries the literal the text prints for it, in both
Expand Down
6 changes: 6 additions & 0 deletions apps/website/qa/queen-continuous-map.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ assert.equal(data.catalogCellAt(world.map,1e8,1e8),-1);
const positions=world.map.positions;
const fieldWidth=Math.max(...positions.map(p=>p.x+HEX_R*p.scale))-Math.min(...positions.map(p=>p.x-HEX_R*p.scale));
const fieldHeight=Math.max(...positions.map(p=>p.y+HEX_R*p.scale))-Math.min(...positions.map(p=>p.y-HEX_R*p.scale));
{const scale=Math.min(...positions.map(p=>p.scale));for(const [width,halfWidth] of [[390,1000],[390,1e6],[1440,5e5]])assert.equal(data.catalogMaxZoom(world.map,halfWidth,width),Math.max(128,520/(S_CELL*scale*width/(halfWidth*2))),'the zoom ceiling is the one that brings the smallest cell to 520 px');}
for(const [width,height] of [[1440,800],[390,600],[390,300]]){
const halfWidth=Math.max(fieldWidth,fieldHeight*width/height)/2*1.2,halfHeight=halfWidth*height/width;
for(const region of world.map.regions){
Expand All @@ -30,6 +31,11 @@ for(const [width,height] of [[1440,800],[390,600],[390,300]]){
// bound is an equality and floating point may land a few 1e-14 px under it (it did at
// 390x300 once the field held eight repositories). A pixel fraction is not a regression.
for(const i of region.indices){const view=data.catalogFocusView(world.map,i,halfWidth,halfHeight,width,height),p=positions[i];assert.equal(view.x,p.x);assert.equal(view.y,p.y);const projected=S_CELL*p.scale*width/(halfWidth*2)*view.zoom;assert(projected>=Math.min(260,width*.84,height*.74)-1e-6,'Close-up stays readable at the actual viewport');}
// A catalogue that grows makes zoom 1 cover more world per pixel. The nightly
// scans of 2026-09-21..23 hit exactly that (11 repositories, the old fixed
// ceiling of 128 fell short), so the same bound must hold on a field eight
// times wider; catalogMaxZoom is what lets it.
for(const i of region.indices){const grown=halfWidth*8,view=data.catalogFocusView(world.map,i,grown,grown*height/width,width,height),p=positions[i];const projected=S_CELL*p.scale*width/(grown*2)*view.zoom;assert(projected>=Math.min(260,width*.84,height*.74)-1e-6,'Close-up stays readable when the catalogue grows');}
}
}
for(const region of world.map.regions){
Expand Down
9 changes: 9 additions & 0 deletions apps/website/qa/queen-hive-display-contract.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,18 @@ assert.equal(h.hiveDisplayLod(340),'detail');
assert.ok(h.hiveFocusZoom(10,390,650)>8);
assert.ok(h.hiveFocusZoom(10,390,650)<=128);
assert.equal(h.hiveFocusZoom(0,390,650),1);
// The ceiling follows the field: a cell 1 px wide at zoom 1 still reaches its readable size.
assert.equal(h.hiveMaxZoom(10),128,'small fields keep the old ceiling');
assert.equal(h.hiveMaxZoom(1),h.HIVE_FOCUS_MAX_PX);
assert.equal(h.hiveMaxZoom(0),128);
assert.equal(h.hiveFocusZoom(1,390,650),128,'without a ceiling the old clamp applies');
assert.ok(Math.abs(h.hiveFocusZoom(1,390,650,h.hiveMaxZoom(1))-390*.84)<1e-9,'with the field ceiling the close-up is exactly readable');
assert.equal(h.hiveIssueUrl(repo,7),'https://github.com/gHashTag/trios/issues/7');
assert.equal(h.hiveIssueUrl('evil.example/a',NaN),null);
const scene=readFileSync(new URL('../src/components/QueenCombBabylon.tsx',import.meta.url),'utf8');
assert.ok(!/Math\.min\(\s*128\b/.test(scene),'no zoom path of the map keeps the fixed ceiling of 128');
assert.equal((scene.match(/Math\.min\(maxZoom\b/g)??[]).length,3,'the wheel, the pinch and the + button clamp to the ceiling fit() computes');
assert.match(scene,/if \(focusIndex === null && zoom > maxZoom\) zoom = zoomGoal = maxZoom;/,'fit() takes an unfocused zoom down when the ceiling drops');
const page=readFileSync(new URL('../src/pages/Queen.tsx',import.meta.url),'utf8');
const cards=readFileSync(new URL('../src/components/QueenHiveDisplays.tsx',import.meta.url),'utf8');
const css=readFileSync(new URL('../src/pages/Queen.css',import.meta.url),'utf8');
Expand Down
19 changes: 12 additions & 7 deletions apps/website/src/components/QueenCombBabylon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import { ImageProcessingConfiguration } from "@babylonjs/core/Materials/imagePro
import { useEffect, useRef, useState, useImperativeHandle } from "react";
import { QueenHiveDisplays, QueenHiveTaskLegend, type HiveDisplayController } from './QueenHiveDisplays';
import { QueenStarfield } from './QueenStarfield';
import {catalogCellAt,catalogFocusView,catalogSpecSelection,catalogConnectionView,type CatalogHive,type CatalogController} from './queenCatalogData';
import { HIVE_TASK_PALETTE, hiveSignalDelivery, hiveSignalRing, hiveRunningIssueNumbers, hiveTaskPaint, hiveTaskCounts, hiveDisplayPaintKey, hiveFocusZoom, hiveDisplayLod, type HiveDisplay, type HiveDisplayProjection, type HiveTaskTone, type HiveSignalHealth, type HiveSignalCursor, type HiveEventRing } from './queenHiveDisplay';
import {catalogCellAt,catalogFocusView,catalogMaxZoom,catalogSpecSelection,catalogConnectionView,type CatalogHive,type CatalogController} from './queenCatalogData';
import { HIVE_TASK_PALETTE, hiveSignalDelivery, hiveSignalRing, hiveRunningIssueNumbers, hiveTaskPaint, hiveTaskCounts, hiveDisplayPaintKey, hiveFocusZoom, hiveMaxZoom, hiveDisplayLod, type HiveDisplay, type HiveDisplayProjection, type HiveTaskTone, type HiveSignalHealth, type HiveSignalCursor, type HiveEventRing } from './queenHiveDisplay';
import { HIVE_WALL_ROTATION, hiveWallToWorld, hiveWorldToWall } from './queenHiveOrientation';
import type { Ref } from "react";
import { TransformNode } from "@babylonjs/core/Meshes/transformNode";
Expand Down Expand Up @@ -357,6 +357,8 @@ export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fit
camera.panningSensibility = 0;
let zoom = savedViewRef.current?.zoom ?? 1;
let zoomGoal = zoom;
// recomputed by fit(): the ceiling follows the field and the viewport (hiveMaxZoom)
let maxZoom = 128;
if (savedViewRef.current) { camera.target.x = savedViewRef.current.x; camera.target.y = savedViewRef.current.y; }
const restoredFocus = savedViewRef.current?.focusKey ? cells.findIndex((_,index)=>viewKey(index)===savedViewRef.current?.focusKey) : -1;
let focusIndex: number | null = restoredFocus >= 0 ? restoredFocus : null;
Expand Down Expand Up @@ -394,9 +396,12 @@ export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fit
const aspect = w / band;
let halfW = fieldW / 2, halfH = halfW / aspect;
if (halfH < fieldH / 2) { halfH = fieldH / 2; halfW = halfH * aspect; }
maxZoom = catalogRef.current ? catalogMaxZoom(catalogRef.current, halfW, w) : hiveMaxZoom(S_CELL * w / (halfW * 2));
// a narrower host lowers the ceiling; take the zoom down with it here, not at the next drag or wheel
if (focusIndex === null && zoom > maxZoom) zoom = zoomGoal = maxZoom;
if (focusIndex !== null && cells[focusIndex]) {
const view=catalogRef.current?(focusConnection?catalogConnectionView(catalogRef.current,focusIndex,halfW,halfH,specInset()):catalogFocusView(catalogRef.current,focusIndex,halfW,halfH,w,band,catalogRef.current.cells[focusIndex]?.kind==='spec'?specInset():undefined)):null;
zoom = zoomGoal = view?.zoom??hiveFocusZoom(S_CELL * cellScale(focusIndex) * w / (halfW * 2), w, band);
const view=catalogRef.current?(focusConnection?catalogConnectionView(catalogRef.current,focusIndex,halfW,halfH,specInset(),maxZoom):catalogFocusView(catalogRef.current,focusIndex,halfW,halfH,w,band,catalogRef.current.cells[focusIndex]?.kind==='spec'?specInset():undefined)):null;
zoom = zoomGoal = view?.zoom??hiveFocusZoom(S_CELL * cellScale(focusIndex) * w / (halfW * 2), w, band, maxZoom);
const focusedPoint = hiveWallToWorld(view?.x??cells[focusIndex].x, view?.y??cells[focusIndex].y);
camera.target.x = focusedPoint.x;
camera.target.y = focusedPoint.y;
Expand Down Expand Up @@ -427,7 +432,7 @@ export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fit
const r = canvas.getBoundingClientRect();
focusIndex = null;
anchor = planeAt(e.clientX - r.left, e.clientY - r.top);
zoomGoal = Math.min(128, Math.max(0.05, zoomGoal * Math.exp(-e.deltaY * 0.0016)));
zoomGoal = Math.min(maxZoom, Math.max(0.05, zoomGoal * Math.exp(-e.deltaY * 0.0016)));
host.setAttribute("data-zoom-goal", zoomGoal.toFixed(2));
};
// Listen above the native spec labels too: their text must not form dead
Expand All @@ -442,7 +447,7 @@ export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fit
anchor=null;
const r = canvas.getBoundingClientRect();
const a=planeAt(movement.from.x-r.left,movement.from.y-r.top);
zoom=zoomGoal=Math.min(128,Math.max(.05,zoom*movement.scale));fit();
zoom=zoomGoal=Math.min(maxZoom,Math.max(.05,zoom*movement.scale));fit();
const b=planeAt(movement.to.x-r.left,movement.to.y-r.top);
if (a && b) {
const from = hiveWallToWorld(a.x,a.z), to = hiveWallToWorld(b.x,b.z);
Expand All @@ -461,7 +466,7 @@ export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fit
window.addEventListener('blur',onBlur);
// the toolbar's FIT VIEW / - / + reach the scene through the same handle the canvas comb exposes
cameraRef.current = {
zoomIn: () => { focusIndex = null; anchor = null; zoom = zoomGoal = Math.min(128, zoom * 1.25); fit(); },
zoomIn: () => { focusIndex = null; anchor = null; zoom = zoomGoal = Math.min(maxZoom, zoom * 1.25); fit(); },
zoomOut: () => { focusIndex = null; anchor = null; zoom = zoomGoal = Math.max(0.05, zoom / 1.25); fit(); },
// FIT VIEW is the way home: it undoes the roam as well as the zoom
fit: () => { focusIndex = null; selectedDisplayRef.current = null; setSelectedDisplayKey(null); host.removeAttribute('data-display-selected'); anchor = null; zoom = zoomGoal = 1; camera.target.copyFrom(centreWorld); fit(); },
Expand Down
20 changes: 14 additions & 6 deletions apps/website/src/components/queenCatalogData.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {HEX_R,S_CELL,hexCellCount,hexRingStart,hexRingsFor,hexToWorld,spiralAxial,hexIndexAt} from './queenHud.ts';
import {keyWorldAtlas,type UniverseAtlas} from '../lib/queenUniverseAtlas.ts';
import type {WorldIssue} from './queenRepositoryWorld.ts';
import {hiveFocusZoom} from './queenHiveDisplay.ts';
import {HIVE_ZOOM_CEILING,hiveFocusZoom,hiveMaxZoom} from './queenHiveDisplay.ts';

export type CatalogCell = {kind:'spec';key:string;specId:string;placement:'core'|'source';sourceRepo?:string;title:string;sources:string[];count:1}
| {kind:'repo';key:string;title:string;repo:string;count:number;open:number|null};
Expand Down Expand Up @@ -110,22 +110,30 @@ export function catalogSpecSelection(map:CatalogHive,index:number|null):{indices
return {indices,links:(map.specLinks??[]).filter(([a,b])=>selected.has(a)&&selected.has(b))};
}

export function catalogConnectionView(map:CatalogHive,index:number,halfWidth:number,halfHeight:number,inset={right:0,bottom:0}) {
const smallestScale=new WeakMap<CatalogHive,number>();
/** The zoom ceiling of this map when zoom 1 spans 2*halfWidth world units across `width` pixels (see hiveMaxZoom). */
export function catalogMaxZoom(map:CatalogHive,halfWidth:number,width:number):number {
let scale=smallestScale.get(map);
if(scale===undefined){scale=Infinity;for(const p of map.positions??[])if(p.scale<scale)scale=p.scale;if(!Number.isFinite(scale))scale=1;smallestScale.set(map,scale);}
return hiveMaxZoom(S_CELL*scale*width/(halfWidth*2));
}

export function catalogConnectionView(map:CatalogHive,index:number,halfWidth:number,halfHeight:number,inset={right:0,bottom:0},maxZoom=HIVE_ZOOM_CEILING) {
const {indices}=catalogSpecSelection(map,index),points=indices.flatMap(i=>map.positions?.[i]?[map.positions[i]]:[]);
if(!points.length)return null;
const minX=Math.min(...points.map(p=>p.x-HEX_R*p.scale)),maxX=Math.max(...points.map(p=>p.x+HEX_R*p.scale));
const minY=Math.min(...points.map(p=>p.y-HEX_R*p.scale)),maxY=Math.max(...points.map(p=>p.y+HEX_R*p.scale));
const zoom=Math.min(128,Math.max(.05,Math.min(halfWidth*2*(1-inset.right)/(maxX-minX+S_CELL),halfHeight*2*(1-inset.bottom)/(maxY-minY+S_CELL))*.78));
const zoom=Math.min(maxZoom,Math.max(.05,Math.min(halfWidth*2*(1-inset.right)/(maxX-minX+S_CELL),halfHeight*2*(1-inset.bottom)/(maxY-minY+S_CELL))*.78));
return {x:(minX+maxX)/2+inset.right*halfWidth/zoom,y:(minY+maxY)/2-inset.bottom*halfHeight/zoom,zoom};
}

/** Region and cell close-ups share the exact camera sizing contract on desktop and mobile. */
export function catalogFocusView(map:CatalogHive,index:number,halfWidth:number,halfHeight:number,width:number,height:number,inset={right:0,bottom:0}) {
const position=map.positions?.[index];if(!position)return null;
const region=map.regions?.find(r=>r.portalIndex===index);
const region=map.regions?.find(r=>r.portalIndex===index),maxZoom=catalogMaxZoom(map,halfWidth,width);
const zoom=region?
Math.min(128,Math.max(.5,Math.min(halfWidth,halfHeight)/(region.focusRadius*1.12))):
hiveFocusZoom(S_CELL*position.scale*width/(halfWidth*2),width*(1-inset.right),height*(1-inset.bottom));
Math.min(maxZoom,Math.max(.5,Math.min(halfWidth,halfHeight)/(region.focusRadius*1.12))):
hiveFocusZoom(S_CELL*position.scale*width/(halfWidth*2),width*(1-inset.right),height*(1-inset.bottom),maxZoom);
return {x:(region?.x??position.x)+(region?0:inset.right*halfWidth/zoom),y:(region?.y??position.y)-(region?0:inset.bottom*halfHeight/zoom),zoom};
}

Expand Down
23 changes: 20 additions & 3 deletions apps/website/src/components/queenHiveDisplay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,10 +165,27 @@ export function hiveDisplayLod(width: number): 'overview' | 'badge' | 'title' |
return width < 64 ? 'overview' : width < 140 ? 'badge' : width < 260 ? 'title' : 'detail';
}

export function hiveFocusZoom(baseCellWidth: number, viewportWidth: number, viewportHeight: number): number {
/** Largest on-screen width, in CSS pixels, a close-up ever asks a cell to reach. */
export const HIVE_FOCUS_MAX_PX = 520;
/** The zoom ceiling the map had when it held five repositories. */
export const HIVE_ZOOM_CEILING = 128;

/**
* Zoom is relative to the whole field (1 shows everything), so a fixed ceiling
* shrinks in pixels as the catalogue grows: with eleven repositories the
* smallest cell needed more than 128 to reach its readable size, the close-up
* contract failed, and the nightly catalogue refresh stopped. The ceiling is the
* zoom at which the smallest cell, `smallestCellWidth` pixels wide at zoom 1,
* reaches HIVE_FOCUS_MAX_PX -- never below the old 128.
*/
export function hiveMaxZoom(smallestCellWidth: number): number {
return smallestCellWidth > 0 ? Math.max(HIVE_ZOOM_CEILING, HIVE_FOCUS_MAX_PX / smallestCellWidth) : HIVE_ZOOM_CEILING;
}

export function hiveFocusZoom(baseCellWidth: number, viewportWidth: number, viewportHeight: number, maxZoom = HIVE_ZOOM_CEILING): number {
if (!(baseCellWidth > 0)) return 1;
const target = Math.max(64, Math.min(520, viewportWidth * .84, viewportHeight * .74));
return Math.min(128, Math.max(.5, target / baseCellWidth));
const target = Math.max(64, Math.min(HIVE_FOCUS_MAX_PX, viewportWidth * .84, viewportHeight * .74));
return Math.min(maxZoom, Math.max(.5, target / baseCellWidth));
}

export interface HiveDisplayProjection { index: number; x: number; y: number; width: number; height: number }
Loading