Shared base #1

Open
Senrokai wants to merge 11 commits from shared-base into main
22 changed files with 1566 additions and 921 deletions
+2
View File
@@ -1,4 +1,6 @@
# Editor configuration, see https://editorconfig.org
# Source: @avalon-vanguard/config. EditorConfig cannot extend a file, so each repo commits a copy:
# cp node_modules/@avalon-vanguard/config/.editorconfig .editorconfig
root = true
[*]
+27
View File
@@ -0,0 +1,27 @@
name: CI
# Gitea port of .github/workflows/ci.yml, through the organisation's shared workflow
# (avalon-vanguard/ci, node.yml): lint, typecheck, unit tests, production build, then the
# Playwright suite. Same triggers: pull requests and the branches they merge into, plus manual
# dispatch.
#
# A .gitea/workflows directory makes Gitea ignore .github/workflows, so pages.yml,
# data-refresh.yml and junie-review.yml now run on GitHub only, until they are ported.
on:
push:
branches: [main, develop]
pull_request:
workflow_dispatch:
# A second push to the same branch makes the first run's answer irrelevant.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
ci:
uses: avalon-vanguard/ci/.gitea/workflows/node.yml@v1
# Pas d'e2e pour l'instant : la carte 3D rendue en logiciel par Chromium, sur ng serve,
# dépasse le runner du VPS (17 tests, 7 en échec par délai dépassé en 16,8 min). À
# remettre (e2e: true) une fois le runner capable de les passer.
secrets: inherit
+1
View File
@@ -0,0 +1 @@
@avalon-vanguard:registry=https://git.avalonvanguard.com/api/packages/avalon-vanguard/npm/
+1 -12
View File
@@ -1,12 +1 @@
{
"printWidth": 100,
"singleQuote": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
}
"@avalon-vanguard/config/prettier/angular"
+37
View File
@@ -0,0 +1,37 @@
// @ts-check
const { defineConfig } = require('eslint/config');
const avalonBase = require('@avalon-vanguard/config/eslint');
const avalonAngular = require('@avalon-vanguard/config/eslint/angular');
module.exports = defineConfig([
{
// Build, test and Playwright output (see .gitignore): ESLint does not read .gitignore.
ignores: [
'dist/**',
'coverage/**',
'.angular/**',
'node_modules/**',
'out-tsc/**',
'tools/etl/.cache/**',
'e2e/test-output/**',
'test-results/**',
'playwright-report/**',
'blob-report/**',
],
},
{
files: ['**/*.ts'],
extends: [avalonBase, avalonAngular.typescript],
rules: {
'@angular-eslint/directive-selector': [
'error',
{ type: 'attribute', prefix: 'app', style: 'camelCase' },
],
'@angular-eslint/component-selector': [
'error',
{ type: 'element', prefix: 'app', style: 'kebab-case' },
],
},
},
avalonAngular.templates,
]);
+1432 -840
View File
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -7,11 +7,13 @@
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test",
"lint": "eslint .",
"etl": "tsx tools/etl/build.ts",
"etl:typecheck": "tsc -p tools/etl/tsconfig.json --noEmit",
"e2e": "playwright test",
"e2e:typecheck": "tsc -p e2e/tsconfig.json --noEmit",
"worker:typecheck": "tsc -p tsconfig.worker.json --noEmit"
"worker:typecheck": "tsc -p tsconfig.worker.json --noEmit",
"typecheck": "npm run etl:typecheck && npm run e2e:typecheck && npm run worker:typecheck"
},
"private": true,
"packageManager": "npm@11.12.1",
@@ -33,16 +35,21 @@
"@angular/build": "^22.0.6",
"@angular/cli": "^22.0.6",
"@angular/compiler-cli": "^22.0.0",
"@avalon-vanguard/config": "^1.0.0",
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.61.1",
"@tailwindcss/postcss": "^4.3.2",
"@types/node": "^26.1.1",
"@types/three": "^0.185.1",
"angular-eslint": "^22.5.0",
"eslint": "^10.10.0",
"jsdom": "^28.0.0",
"postcss": "^8.5.19",
"prettier": "^3.8.1",
"tailwindcss": "^4.3.2",
"tsx": "^4.23.1",
"typescript": "~6.0.2",
"typescript-eslint": "^8.70.0",
"vitest": "^4.0.8"
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"local>avalon-vanguard/renovate-config",
"local>avalon-vanguard/renovate-config:angular"
]
}
+3 -2
View File
@@ -1,4 +1,4 @@
import { NgZone } from '@angular/core';
import { Injector, NgZone, runInInjectionContext } from '@angular/core';
import * as THREE from 'three/webgpu';
import { beforeEach, describe, expect, it } from 'vitest';
@@ -10,7 +10,8 @@ import { EngineService } from './engine.service';
* arithmetic that keeps them showing the same thing.
*/
function engineWithCameras(): { engine: EngineService; perspective: THREE.PerspectiveCamera; orthographic: THREE.OrthographicCamera } {
const engine = new EngineService({ runOutsideAngular: (fn: () => unknown) => fn() } as unknown as NgZone);
const zone = { runOutsideAngular: (fn: () => unknown) => fn() } as unknown as NgZone;
const engine = runInInjectionContext(Injector.create({ providers: [{ provide: NgZone, useValue: zone }] }), () => new EngineService());
const perspective = new THREE.PerspectiveCamera(50, 16 / 9, 0.1, 1000);
const orthographic = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 1000);
// The two cameras are private, because nothing outside should choose between them by hand.
+2 -3
View File
@@ -1,4 +1,4 @@
import { Injectable, NgZone } from '@angular/core';
import { inject, Injectable, NgZone } from '@angular/core';
import * as THREE from 'three/webgpu';
export type EngineTickCallback = (deltaSeconds: number, elapsedSeconds: number) => void;
@@ -20,6 +20,7 @@ export type SceneCamera = THREE.PerspectiveCamera | THREE.OrthographicCamera;
*/
@Injectable()
export class EngineService {
private readonly ngZone = inject(NgZone);
private readonly clock = new THREE.Clock(false);
private readonly tickCallbacks = new Set<EngineTickCallback>();
@@ -36,8 +37,6 @@ export class EngineService {
private projection: Projection = 'perspective';
private running = false;
constructor(private readonly ngZone: NgZone) {}
get isInitialized(): boolean {
return !!this.renderer;
}
@@ -1,4 +1,4 @@
import { AfterViewInit, Component, ElementRef, OnDestroy, signal, viewChild } from '@angular/core';
import { AfterViewInit, Component, ElementRef, inject, OnDestroy, signal, viewChild } from '@angular/core';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { Subscription } from 'rxjs';
import * as THREE from 'three/webgpu';
@@ -65,6 +65,12 @@ const GLOW_SCALE = 2.6;
`
})
export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy {
private readonly engine = inject(EngineService);
private readonly dataLoader = inject(DataLoaderService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly navigationStore = inject(NavigationStore);
/** A kept place, revisited from this page: a star means leaving it for the map. */
goToBookmark(bookmark: Bookmark): void {
if (bookmark.kind === 'star') {
@@ -95,14 +101,6 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy {
readonly viewModel = signal<BodyDetailViewModel | undefined>(undefined);
readonly notFound = signal(false);
constructor(
private readonly engine: EngineService,
private readonly dataLoader: DataLoaderService,
private readonly route: ActivatedRoute,
private readonly router: Router,
private readonly navigationStore: NavigationStore
) {}
ngAfterViewInit(): void {
void this.bootstrap();
}
@@ -273,7 +271,6 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy {
scene.add(sunLight);
const geometry = new THREE.SphereGeometry(1, 64, 48);
const viewModel = this.viewModel();
this.planetMaterial = new THREE.MeshStandardMaterial({
// White, always: the map that arrives a moment later carries the colour, whether it is a
// photograph or a surface derived from the body's own measurements.
@@ -98,6 +98,8 @@ import { ReadoutSectionsComponent } from './readout-sections.component';
export class InfoPanelComponent {
readonly body = input.required<BodyDetailViewModel>();
private readonly router = inject(Router);
readonly bookmarks = inject(BookmarksStore);
private readonly articles = inject(ArticleService);
@@ -105,7 +107,7 @@ export class InfoPanelComponent {
readonly article = signal<Article | null>(null);
readonly aboutState = signal<'idle' | 'loading' | 'none' | 'unavailable'>('idle');
constructor(private readonly router: Router) {
constructor() {
// The panel is reused as the route's parameter changes, so what was asked about one body
// must not still be showing under the next one's name.
effect(() => {
@@ -604,7 +604,7 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
}
it('links the stars being drawn, and asks again once a new set of them holds still', async () => {
const links = vi.fn((_rangePc: number, _drawn: Uint32Array) => Promise.resolve(new Float32Array(0)));
const links = vi.fn<LinkScene['routing']['links']>(() => Promise.resolve(new Float32Array(0)));
const component = linkScene(links);
await settle();
expect(links).toHaveBeenCalledTimes(1);
@@ -625,7 +625,7 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
});
it('asks for as much of the graph as a million pixels of line make, around where the view is centred', async () => {
const links = vi.fn((_rangePc: number, _drawn: Uint32Array, _budget?: LinkBudget) => Promise.resolve(new Float32Array(0)));
const links = vi.fn<LinkScene['routing']['links']>(() => Promise.resolve(new Float32Array(0)));
Object.defineProperty((fixture.nativeElement as HTMLElement).querySelector('canvas')!, 'clientHeight', { value: 1080 });
// A screen scaled to 200%: 1080 CSS pixels are 2160 drawn ones, and the lines are drawn in those.
engine.pixelRatio = 2;
@@ -641,7 +641,7 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
it('asks again once the view has zoomed past the budget it asked with, though the drawn stars are the same', async () => {
// All three stars fit the star budget, so the drawn set never changes: only the budget can.
const links = vi.fn((_rangePc: number, _drawn: Uint32Array, _budget?: LinkBudget) => Promise.resolve(new Float32Array(0)));
const links = vi.fn<LinkScene['routing']['links']>(() => Promise.resolve(new Float32Array(0)));
Object.defineProperty((fixture.nativeElement as HTMLElement).querySelector('canvas')!, 'clientHeight', { value: 1080 });
const component = linkScene(links);
await advanceFrames(engine, 0.3);
@@ -658,7 +658,7 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
});
it('asks for no graph from inside a system, where distances are in astronomical units', async () => {
const links = vi.fn((_rangePc: number, _drawn: Uint32Array, _budget?: LinkBudget) => Promise.resolve(new Float32Array(0)));
const links = vi.fn<LinkScene['routing']['links']>(() => Promise.resolve(new Float32Array(0)));
navigationStore.selectStar(SUN.id);
await flushAsync();
await advanceFrames(engine, 2.5);
@@ -695,7 +695,7 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
});
it('gives a view on the move a new graph at least every quarter second, rather than waiting for it to stop', async () => {
const links = vi.fn((_rangePc: number, _drawn: Uint32Array) => Promise.resolve(new Float32Array(0)));
const links = vi.fn<LinkScene['routing']['links']>(() => Promise.resolve(new Float32Array(0)));
const component = linkScene(links);
await settle();
@@ -1,4 +1,4 @@
import { AfterViewInit, Component, computed, effect, ElementRef, OnDestroy, signal, viewChild } from '@angular/core';
import { AfterViewInit, Component, computed, effect, ElementRef, inject, OnDestroy, signal, viewChild } from '@angular/core';
import { Router } from '@angular/router';
import * as THREE from 'three/webgpu';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
@@ -323,6 +323,11 @@ function galacticOverviewPose(): { position: THREE.Vector3; target: THREE.Vector
`
})
export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
private readonly engine = inject(EngineService);
private readonly dataLoader = inject(DataLoaderService);
private readonly router = inject(Router);
readonly navigationStore = inject(NavigationStore);
private readonly canvasRef = viewChild.required<ElementRef<HTMLCanvasElement>>('canvas');
private readonly labelHostRef = viewChild.required<ElementRef<HTMLDivElement>>('labelHost');
private readonly leaderRef = viewChild.required<ElementRef<SVGLineElement>>('leader');
@@ -467,12 +472,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
private starMarker?: THREE.Mesh;
private starGlow?: THREE.Sprite;
constructor(
private readonly engine: EngineService,
private readonly dataLoader: DataLoaderService,
private readonly router: Router,
readonly navigationStore: NavigationStore
) {
constructor() {
effect(() => {
const selectedStarId = this.navigationStore.selectedStarId();
if (this.ready) {
@@ -484,7 +484,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
// Reads both signals, so flipping the layer on and dragging the range each land here.
effect(() => {
this.jumpRangePc();
this.display().jumpLinks;
void this.display().jumpLinks;
this.scheduleJumpLinks();
});
}
@@ -1804,8 +1804,6 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
}
private swapToSystemSpace(star: StarRecord, direction: THREE.Vector3, onComplete: () => void): void {
const camera = this.engine.getCamera();
this.systemRenderer?.dispose();
if (this.starMarker) {
this.systemGroup.remove(this.starMarker);
@@ -1912,7 +1910,6 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
}
private swapToGalaxySpace(exitingStarId: number, direction: THREE.Vector3, isSwitchingSystems: boolean, onComplete: () => void): void {
const camera = this.engine.getCamera();
const star = this.starsById.get(exitingStarId);
const starPc = star ? new THREE.Vector3(star.x, star.y, star.z) : GALAXY_OVERVIEW_TARGET.clone();
+1 -1
View File
@@ -1,6 +1,6 @@
import * as THREE from 'three/webgpu';
import { GALACTIC_BASIS_EQUATORIAL, SUN_HEIGHT_ABOVE_MIDPLANE_PC } from '../../shared/astro/galaxy';
import { GALACTIC_BASIS_EQUATORIAL } from '../../shared/astro/galaxy';
const SEGMENTS_PER_RING = 180;
@@ -87,9 +87,9 @@ export function createRandom(seed: number): () => number {
/** Standard normal sample, by the polar form of Box-Muller. */
function gaussian(random: () => number): number {
let u = 0;
let v = 0;
let s = 0;
let u: number;
let v: number;
let s: number;
do {
u = random() * 2 - 1;
v = random() * 2 - 1;
@@ -5,7 +5,7 @@ import { BrightnessIndex, brightnessIndex, Positioned } from '../../shared/astro
import { spectralTypeToColorIndex } from '../../shared/astro/spectral';
import { SceneCamera } from '../../core/engine/engine.service';
import { StarRecord } from '../../shared/models/star.model';
import { PIXELS_TO_ANGULAR_SIZE, REFERENCE_FOV_DEGREES, REFERENCE_VIEWPORT_HEIGHT_PX } from './angular-size';
import { PIXELS_TO_ANGULAR_SIZE, REFERENCE_FOV_DEGREES } from './angular-size';
/** Apparent star diameters, in pixels at {@link REFERENCE_VIEWPORT_HEIGHT_PX}. */
const MIN_POINT_SIZE = 1.5;
+6 -6
View File
@@ -1,4 +1,4 @@
import { Component, computed, ElementRef, output, signal, viewChild } from '@angular/core';
import { Component, computed, ElementRef, inject, output, signal, viewChild } from '@angular/core';
import { Router } from '@angular/router';
import { DataLoaderService } from '../../core/data/data-loader.service';
@@ -80,6 +80,10 @@ const KIND_LABELS: Record<SearchResultKind, string> = {
`
})
export class SearchComponent {
private readonly dataLoader = inject(DataLoaderService);
private readonly navigationStore = inject(NavigationStore);
private readonly router = inject(Router);
readonly query = signal('');
/** Fires once a result has been chosen and navigation kicked off — the dock uses it to hand
* the view back to the readout, since the thing to look at is now the scene, not the box. */
@@ -109,11 +113,7 @@ export class SearchComponent {
readonly matchTotal = computed(() => this.matches().length);
readonly results = computed(() => this.matches().slice(0, MAX_RESULTS));
constructor(
private readonly dataLoader: DataLoaderService,
private readonly navigationStore: NavigationStore,
private readonly router: Router
) {
constructor() {
void this.buildIndex();
}
+2 -1
View File
@@ -168,7 +168,8 @@ describe('minimumRangeBetween', () => {
expect(minimumRangeBetween(knotAndChain(), 0, CHAIN_END, 8).rangePc).toBeCloseTo(1, 1);
});
it('stops bisecting where a search gave up, and hands back a range that does work', { timeout: 30_000 }, () => {
// 7,5 s sur un poste récent, autour de 30 s sur le runner Gitea du VPS : 90 s de marge.
it('stops bisecting where a search gave up, and hands back a range that does work', { timeout: 90_000 }, () => {
// Below the chain's own hop of a parsec, the crowd is still one connected piece and larger than
// the budget, so those probes give up. Reading a give-up as "no chain at this range" is what
// used to report ranges up to 29% wider than needed, and went on paying for probes whose
@@ -115,11 +115,12 @@ describe('renderPlanetTexture', () => {
it('produces no NaN or out-of-range bytes for any class', () => {
for (const planetClass of ALL_CLASSES) {
const pixels = renderPlanetTexture(appearanceOf(planetClass, { polarCapExtentDeg: 30 }), SIZE);
for (const value of pixels) {
expect(Number.isInteger(value)).toBe(true);
expect(value).toBeGreaterThanOrEqual(0);
expect(value).toBeLessThanOrEqual(255);
}
// One assertion per class, not three per byte: 245 760 expect() calls ran past the
// default 5 s timeout on a slower CI machine.
const firstBad = pixels.findIndex(
(value) => !Number.isInteger(value) || value < 0 || value > 255,
);
expect(firstBad, planetClass).toBe(-1);
}
});
});
+1 -1
View File
@@ -82,7 +82,7 @@ export class BookmarksStore {
}
private read(): Bookmark[] {
let raw: string | null = null;
let raw: string | null;
try {
raw = localStorage.getItem(STORAGE_KEY);
} catch {
+2 -17
View File
@@ -1,24 +1,9 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
/* Compiler and Angular options come from @avalon-vanguard/config (tsconfig/angular.json). */
{
"extends": "@avalon-vanguard/config/tsconfig/angular.json",
"compileOnSave": false,
"compilerOptions": {
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"experimentalDecorators": true,
"importHelpers": true,
"target": "ES2022",
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true
},
"files": [],
"references": [
{