@inventure71/paddockjs 4.1.0 → 4.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +76 -0
- package/INSTALL_AND_UPDATE.md +31 -5
- package/README.md +34 -3
- package/docs/advanced_ai_pace.md +131 -0
- package/docs/advanced_physics_rebuild.md +166 -0
- package/docs/architecture.md +111 -30
- package/docs/cleanup_coverage.md +284 -0
- package/docs/component_inventory.md +32 -16
- package/docs/composable-layouts.md +2 -0
- package/docs/concepts.md +12 -1
- package/docs/custom_model_controller.md +1 -1
- package/docs/data_contract.md +33 -10
- package/docs/data_model.md +4 -2
- package/docs/efficiency_cleanup_2026-10-13.md +80 -0
- package/docs/getting-started.md +3 -0
- package/docs/grid_walk.md +80 -0
- package/docs/index.md +5 -2
- package/docs/keyboard_driving_fix.md +118 -0
- package/docs/limit_driving_2026-10-14.md +88 -0
- package/docs/loading.md +141 -0
- package/docs/maintainability_audit_2026-09-08.md +146 -0
- package/docs/racing_ai_improvements.md +145 -0
- package/docs/rules.md +43 -13
- package/docs/sense_contract.md +4 -1
- package/docs/system_specs.md +40 -12
- package/docs/testing-reliability-guide.md +67 -0
- package/docs/v11_improvements.md +108 -0
- package/package.json +23 -15
- package/src/api/MountedSimulatorComponents.js +101 -0
- package/src/api/PaddockSimulatorController.js +56 -81
- package/src/app/BrowserExpertAdapter.js +0 -6
- package/src/app/BrowserExpertExternalRenderer.js +72 -50
- package/src/app/ExternalTrackSnapshot.js +50 -0
- package/src/app/F1SimulatorApp.js +242 -297
- package/src/app/HostLifecycleEmitter.js +93 -0
- package/src/app/banners/raceDataBannerController.js +9 -4
- package/src/app/camera/cameraConstants.js +1 -1
- package/src/app/camera/cameraController.js +10 -13
- package/src/app/camera/cameraGeometry.js +1 -1
- package/src/app/controls/SimulatorViewControls.js +45 -0
- package/src/app/controls/workbenchBindings.js +64 -0
- package/src/app/controls/workbenchDisclosure.js +41 -0
- package/src/app/domBindings.js +21 -13
- package/src/app/gridWalk/GridWalkController.js +180 -0
- package/src/app/gridWalk/gridWalkCamera.js +88 -0
- package/src/app/gridWalk/gridWalkControls.js +24 -0
- package/src/app/gridWalk/gridWalkCrew.js +246 -0
- package/src/app/gridWalk/gridWalkLabels.js +104 -0
- package/src/app/gridWalk/gridWalkLayout.js +46 -0
- package/src/app/gridWalk/gridWalkNavigation.js +99 -0
- package/src/app/gridWalk/gridWalkProjectBanner.js +43 -0
- package/src/app/gridWalk/gridWalkSession.js +50 -0
- package/src/app/layoutSupport.js +2 -0
- package/src/app/raceOverlayClearanceSupport.js +21 -7
- package/src/app/readouts/carOverviewRenderer.js +9 -4
- package/src/app/readouts/readoutFormatters.js +1 -8
- package/src/app/readouts/telemetryRenderer.js +1 -3
- package/src/app/readouts/timingTowerRenderer.js +48 -50
- package/src/app/rendering/appAssets.js +8 -10
- package/src/app/rendering/carRenderer.js +27 -79
- package/src/app/rendering/displayUtils.js +0 -6
- package/src/app/rendering/expertSensorRenderer.js +1 -1
- package/src/app/rendering/replayGhostRenderer.js +4 -1
- package/src/app/rendering/trackRenderer.js +2 -2
- package/src/app/runtime/frameLoop.js +18 -17
- package/src/app/runtime/pixiStartupLifecycle.js +94 -0
- package/src/app/runtime/runtimeTiming.js +4 -1
- package/src/app/runtime/runtimeVisibility.js +7 -2
- package/src/config/cssValues.js +1 -1
- package/src/config/defaultOptions.js +12 -173
- package/src/config/restartOptions.js +1 -0
- package/src/config/runtimeSeed.js +10 -0
- package/src/config/simulatorDefaults.js +106 -0
- package/src/config/telemetryModules.js +20 -0
- package/src/config/theme/cssTokens.js +85 -0
- package/src/config/theme/themeCss.js +72 -0
- package/src/config/theme/themeInputs.js +155 -0
- package/src/config/theme/themeResolver.js +172 -0
- package/src/config/theme/themeSelectors.js +69 -0
- package/src/config/theme/themeTokens.js +257 -0
- package/src/config/theme/themeUtils.js +15 -0
- package/src/config/themeOptions.js +14 -830
- package/src/config/timingGapMode.js +0 -2
- package/src/config/uiOptions.js +53 -0
- package/src/data/championship.js +16 -158
- package/src/data/championshipDefaults.js +87 -0
- package/src/data/driverData.js +10 -20
- package/src/data/entryIdentity.js +67 -0
- package/src/data/entryPresentation.js +23 -0
- package/src/data/entryTypes.d.ts +160 -0
- package/src/data/index.d.ts +12 -195
- package/src/data/ratingScale.js +14 -0
- package/src/data/teamTypes.d.ts +23 -0
- package/src/data/trackTypes.d.ts +101 -0
- package/src/data/vehicleData.js +12 -23
- package/src/environment/actions.js +3 -2
- package/src/environment/controllerLoop.js +57 -44
- package/src/environment/controllerScheduler.js +30 -0
- package/src/environment/driverObservations.js +260 -0
- package/src/environment/driverReset.js +51 -0
- package/src/environment/episode.js +0 -1
- package/src/environment/evaluation.js +6 -90
- package/src/environment/evaluationTracker.js +100 -0
- package/src/environment/events.js +23 -0
- package/src/environment/index.d.ts +35 -91
- package/src/environment/metrics.js +3 -17
- package/src/environment/observationSchema.js +104 -0
- package/src/environment/observationVector.js +16 -115
- package/src/environment/observations.js +16 -316
- package/src/environment/options.js +5 -24
- package/src/environment/renderFrames.js +21 -0
- package/src/environment/resetOptions.js +41 -0
- package/src/environment/results.js +125 -0
- package/src/environment/rewards.js +10 -16
- package/src/environment/runtime.js +17 -300
- package/src/environment/scenarioOptions.js +90 -0
- package/src/environment/scenarioPlacements.js +70 -0
- package/src/environment/scenarioPresets.js +78 -0
- package/src/environment/scenarios.js +5 -236
- package/src/environment/scratch.js +18 -0
- package/src/environment/sensorOptions.js +20 -0
- package/src/environment/sensors/index.js +2 -556
- package/src/environment/sensors/indexedRayBands.js +0 -18
- package/src/environment/sensors/nearbyCars.js +18 -15
- package/src/environment/sensors/opponentRadar.js +3 -2
- package/src/environment/sensors/pitConnectorProximity.js +20 -0
- package/src/environment/sensors/rayBandTrace.js +1 -37
- package/src/environment/sensors/rayBatch.js +81 -0
- package/src/environment/sensors/rayConfig.js +26 -0
- package/src/environment/sensors/rayGeometry.js +0 -8
- package/src/environment/sensors/rayGuards.js +0 -6
- package/src/environment/sensors/rayOutputs.js +75 -0
- package/src/environment/sensors/rayQuery.js +110 -0
- package/src/environment/sensors/rayScratch.js +146 -0
- package/src/environment/sensors/sensorTargets.js +12 -4
- package/src/environment/sensors/surfaceRays.js +6 -61
- package/src/environment/sensors/trackRays.js +7 -80
- package/src/environment/simulationSetup.js +30 -0
- package/src/environment/specs.js +15 -109
- package/src/index.d.ts +43 -202
- package/src/index.js +28 -6
- package/src/placeholder/index.js +1 -8
- package/src/rendering/displayLifecycle.js +6 -0
- package/src/rendering/proceduralTrackAsset.js +1 -1
- package/src/rendering/renderSnapshot.js +3 -0
- package/src/rendering/track/finishLineRenderer.js +1 -1
- package/src/rendering/track/gridRenderer.js +1 -1
- package/src/rendering/track/offsetStrokeSafety.js +2 -2
- package/src/rendering/track/pitLaneRenderer.js +1 -1
- package/src/rendering/track/trackMaterialRenderer.js +4 -4
- package/src/rendering/track/trackRenderConstants.js +0 -1
- package/src/rendering/track/trackRenderGeometry.js +1 -7
- package/src/simulation/collisionGeometry.js +12 -20
- package/src/simulation/driver/advancedPathControls.js +53 -0
- package/src/simulation/driver/advancedRacePace.js +112 -0
- package/src/simulation/driver/advancedRacingControls.js +61 -0
- package/src/simulation/driver/advancedRacingEnvelope.js +255 -0
- package/src/simulation/driver/advancedRacingSteering.js +76 -0
- package/src/simulation/driver/advancedRacingTiming.js +16 -0
- package/src/simulation/driver/arcadeRacePace.js +83 -0
- package/src/simulation/driver/arcadeRacingControls.js +111 -0
- package/src/simulation/driver/arcadeRacingEnvelope.js +63 -0
- package/src/simulation/driver/driverControlConstants.js +0 -2
- package/src/simulation/driver/driverController.js +16 -5
- package/src/simulation/driver/edgeRecovery.js +2 -3
- package/src/simulation/driver/racingControls.js +10 -396
- package/src/simulation/driver/racingDriverProfile.js +48 -0
- package/src/simulation/driver/racingLinePlan.js +54 -20
- package/src/simulation/driver/rejoinControls.js +59 -112
- package/src/simulation/driver/safetyCarControls.js +12 -0
- package/src/simulation/driver/trafficScan.js +136 -27
- package/src/simulation/participants/participantInteractions.js +24 -15
- package/src/simulation/pit/pitCarPlacement.js +29 -0
- package/src/simulation/pit/pitFlow.js +8 -30
- package/src/simulation/pit/pitIntent.js +4 -3
- package/src/simulation/pit/pitOccupancy.js +2 -2
- package/src/simulation/pit/pitQueue.js +2 -18
- package/src/simulation/pit/pitRouteMovement.js +1 -36
- package/src/simulation/pit/pitRouting.js +5 -81
- package/src/simulation/pit/pitService.js +3 -32
- package/src/simulation/pit/pitServiceConstants.js +1 -0
- package/src/simulation/pit/pitServiceProgress.js +34 -0
- package/src/simulation/pit/pitSnapshots.js +8 -4
- package/src/simulation/pit/pitState.js +1 -5
- package/src/simulation/race/classification.js +1 -8
- package/src/simulation/race/raceFinish.js +3 -8
- package/src/simulation/race/raceOrder.js +2 -13
- package/src/simulation/race/raceProgress.js +6 -20
- package/src/simulation/race/raceSetup.js +1 -1
- package/src/simulation/race/raceStep.js +20 -11
- package/src/simulation/race/redFlag.js +2 -4
- package/src/simulation/race/safetyCar.js +5 -9
- package/src/simulation/race/stalledDnf.js +2 -4
- package/src/simulation/raceSimulation.js +6 -38
- package/src/simulation/replay/replayGhosts.js +44 -32
- package/src/simulation/rules/collisionSteward.js +0 -14
- package/src/simulation/rules/penaltyActions.js +37 -0
- package/src/simulation/rules/penaltyLedger.js +8 -1
- package/src/simulation/rules/pitLaneSpeedingSteward.js +1 -1
- package/src/simulation/rules/rulesReview.js +20 -16
- package/src/simulation/rules/trackLimitsSteward.js +2 -2
- package/src/simulation/simMath.js +4 -0
- package/src/simulation/snapshots/raceSnapshots.js +30 -11
- package/src/simulation/timing/drsState.js +8 -0
- package/src/simulation/timing/drsTiming.js +2 -4
- package/src/simulation/timing/lapTelemetry.js +7 -4
- package/src/simulation/timing/sectorPerformance.js +14 -6
- package/src/simulation/timing/timingLines.js +12 -6
- package/src/simulation/track/pitLaneAccess.js +2 -2
- package/src/simulation/track/pitLaneLayout.js +4 -135
- package/src/simulation/track/pitLaneModel.js +70 -0
- package/src/simulation/track/pitLaneState.js +0 -1
- package/src/simulation/track/pitServiceLayout.js +121 -0
- package/src/simulation/track/proceduralTrack.js +77 -0
- package/src/simulation/track/spatialQueries.js +76 -31
- package/src/simulation/track/trackBuilder.js +54 -0
- package/src/simulation/track/trackConstants.js +0 -22
- package/src/simulation/track/trackMath.js +4 -29
- package/src/simulation/track/trackModel.js +12 -240
- package/src/simulation/track/trackModelCache.js +105 -0
- package/src/simulation/track/trackNearestQueries.js +1218 -0
- package/src/simulation/track/trackQueryConstants.js +3 -0
- package/src/simulation/track/trackQueryConstruction.js +368 -0
- package/src/simulation/track/trackQueryIndex.js +12 -1933
- package/src/simulation/track/trackQuerySegments.js +174 -0
- package/src/simulation/track/trackQueryStats.js +12 -0
- package/src/simulation/track/trackRayQueries.js +165 -0
- package/src/simulation/vehicle/advancedContactResponse.js +89 -0
- package/src/simulation/vehicle/advancedTireForces.js +92 -0
- package/src/simulation/vehicle/advancedVehicleModel.js +65 -0
- package/src/simulation/vehicle/advancedVehiclePhysics.js +127 -0
- package/src/simulation/vehicle/contactResolution.js +23 -9
- package/src/simulation/vehicle/mainTrackWheelSurface.js +2 -13
- package/src/simulation/vehicle/pitWheelSurface.js +0 -12
- package/src/simulation/vehicle/runoffResponse.js +2 -4
- package/src/simulation/vehicle/sampledWheelSurface.js +266 -0
- package/src/simulation/vehicle/vehicleGeometry.js +85 -103
- package/src/simulation/vehicle/vehicleKinematics.js +10 -0
- package/src/simulation/vehicle/vehiclePhysics.js +42 -196
- package/src/simulation/vehicle/vehicleSnapshots.js +18 -6
- package/src/simulation/vehicle/vehicleState.js +5 -3
- package/src/simulation/vehicle/wheelSurface.js +13 -432
- package/src/simulation/vehicle/wheelSurfaceCache.js +55 -0
- package/src/simulation/vehicle/wheelSurfaceSummary.js +101 -0
- package/src/styles.css +173 -19
- package/src/ui/bannerTemplates.js +4 -2
- package/src/ui/cameraControlsTemplate.js +4 -2
- package/src/ui/gridWalkTemplate.js +29 -0
- package/src/ui/htmlEscaping.js +8 -0
- package/src/ui/raceCanvasTemplate.js +3 -2
- package/src/ui/shellTemplate.js +3 -0
- package/src/ui/telemetryDrawerTemplate.js +4 -2
- package/src/ui/telemetryTemplates.js +3 -22
- package/src/ui/templateUtils.js +2 -8
- package/src/ui/viewControlsTemplate.js +21 -0
- package/examples/train-basic-policy.mjs +0 -276
- package/examples/trainingData.mjs +0 -133
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
export function gridWalkOrientation(width, height, previous = null) {
|
|
2
|
+
const ratio = width / Math.max(1, height);
|
|
3
|
+
if (previous === 'horizontal' && ratio >= 0.92) return previous;
|
|
4
|
+
if (previous === 'vertical' && ratio <= 1.08) return previous;
|
|
5
|
+
return ratio >= 1 ? 'horizontal' : 'vertical';
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function gridAxes(cars) {
|
|
9
|
+
const heading = cars[0].heading;
|
|
10
|
+
const cos = Math.cos(heading);
|
|
11
|
+
const sin = Math.sin(heading);
|
|
12
|
+
const along = cars.map((car) => car.x * cos + car.y * sin);
|
|
13
|
+
const across = cars.map((car) => -car.x * sin + car.y * cos);
|
|
14
|
+
return { heading, cos, sin, along, across };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// A camera has one position along the original grid. Selection, orientation and
|
|
18
|
+
// container sizing never introduce a second axis or a different framing mode.
|
|
19
|
+
export function gridWalkCameraFrame({ cars, selectedId, width, height, orientation, bannerSize = 0, area,
|
|
20
|
+
position = null }) {
|
|
21
|
+
const { heading, cos, sin, along, across } = gridAxes(cars);
|
|
22
|
+
const horizontal = orientation === 'horizontal';
|
|
23
|
+
area ??= { left: 16, top: width < 600 ? 144 : 82, right: width - 16, bottom: height - 16 - bannerSize };
|
|
24
|
+
const availableWidth = Math.max(1, area.right - area.left);
|
|
25
|
+
const availableHeight = Math.max(1, area.bottom - area.top);
|
|
26
|
+
const crossSpan = Math.max(...across) - Math.min(...across);
|
|
27
|
+
const spacing = cars.length > 1 ? (Math.max(...along) - Math.min(...along)) / (cars.length - 1) : 96;
|
|
28
|
+
const scale = Math.max(0.05, Math.min(
|
|
29
|
+
(horizontal ? availableWidth : availableHeight) / (spacing * 2 + 180),
|
|
30
|
+
(horizontal ? availableHeight : availableWidth) / (crossSpan + (horizontal ? 180 : 210)), 2.4,
|
|
31
|
+
));
|
|
32
|
+
const selected = Math.max(0, cars.findIndex((car) => car.id === selectedId));
|
|
33
|
+
const minPosition = Math.min(...along) - spacing;
|
|
34
|
+
const maxPosition = Math.max(...along) + spacing;
|
|
35
|
+
const activePosition = Math.max(minPosition, Math.min(maxPosition, position ?? along[selected]));
|
|
36
|
+
const cross = (Math.min(...across) + Math.max(...across)) / 2;
|
|
37
|
+
const center = { x: activePosition * cos - cross * sin, y: activePosition * sin + cross * cos };
|
|
38
|
+
const rotation = (horizontal ? 0 : -Math.PI / 2) - heading;
|
|
39
|
+
const rcos = Math.cos(rotation);
|
|
40
|
+
const rsin = Math.sin(rotation);
|
|
41
|
+
const frame = {
|
|
42
|
+
rotation, scale, center, position: activePosition, minPosition, maxPosition, orientation, area,
|
|
43
|
+
x: area.left + availableWidth / 2 - (center.x * rcos - center.y * rsin) * scale,
|
|
44
|
+
y: area.top + availableHeight / 2 - (center.x * rsin + center.y * rcos) * scale,
|
|
45
|
+
};
|
|
46
|
+
frame.visibleIds = cars.filter((car) => isGridPointVisible(frame, car)).map((car) => car.id);
|
|
47
|
+
return frame;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function isGridPointVisible(frame, car, margin = 24) {
|
|
51
|
+
const point = projectGridPoint(frame, car);
|
|
52
|
+
const { area } = frame;
|
|
53
|
+
return point.x >= area.left + margin && point.x <= area.right - margin &&
|
|
54
|
+
point.y >= area.top + margin && point.y <= area.bottom - margin;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function projectGridPoint(frame, point) {
|
|
58
|
+
const cos = Math.cos(frame.rotation);
|
|
59
|
+
const sin = Math.sin(frame.rotation);
|
|
60
|
+
return { x: frame.x + (point.x * cos - point.y * sin) * frame.scale,
|
|
61
|
+
y: frame.y + (point.x * sin + point.y * cos) * frame.scale };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export class GridWalkCamera {
|
|
65
|
+
constructor(cars) { this.cars = cars; this.position = null; }
|
|
66
|
+
|
|
67
|
+
update(input) {
|
|
68
|
+
this.frame = gridWalkCameraFrame({ ...input, cars: this.cars, position: this.position });
|
|
69
|
+
this.position = this.frame.position;
|
|
70
|
+
return this.frame;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
pan(delta) {
|
|
74
|
+
if (!this.frame || !Number.isFinite(delta) || !delta) return false;
|
|
75
|
+
const sign = this.frame.orientation === 'horizontal' ? -1 : 1;
|
|
76
|
+
const position = Math.max(this.frame.minPosition, Math.min(this.frame.maxPosition,
|
|
77
|
+
this.position + sign * delta / this.frame.scale));
|
|
78
|
+
if (position === this.position) return false;
|
|
79
|
+
this.position = position;
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
focus(id) {
|
|
84
|
+
const index = this.cars.findIndex((car) => car.id === id);
|
|
85
|
+
if (index < 0) return;
|
|
86
|
+
this.position = gridAxes(this.cars).along[index];
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export class GridWalkControls {
|
|
2
|
+
constructor(element, { onSelect, onMove }, signal) {
|
|
3
|
+
this.element = element.querySelector('[data-grid-header]');
|
|
4
|
+
this.navigation = element.querySelector('[data-grid-navigation]');
|
|
5
|
+
this.jump = element.querySelector('[data-grid-jump]');
|
|
6
|
+
this.status = element.querySelector('[data-grid-status]');
|
|
7
|
+
this.element.addEventListener('click', (event) => {
|
|
8
|
+
const button = event.target.closest('button');
|
|
9
|
+
if (button?.hasAttribute('data-grid-next')) onMove(1);
|
|
10
|
+
else if (button?.hasAttribute('data-grid-previous')) onMove(-1);
|
|
11
|
+
}, { signal });
|
|
12
|
+
this.jump.addEventListener('change', () => onSelect(this.jump.value), { signal });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
update(session, selectedId) {
|
|
16
|
+
this.element.hidden = !session.active || session.clearing;
|
|
17
|
+
this.navigation.hidden = !session.active || session.clearing;
|
|
18
|
+
this.jump.value = selectedId;
|
|
19
|
+
const status = session.clearing ? 'Crew clearing the grid' : session.view === 'grid-walk'
|
|
20
|
+
? session.hasRaced ? 'Race paused · original starting grid' : 'Explore the starting grid'
|
|
21
|
+
: session.hasRaced ? 'Race in progress' : '';
|
|
22
|
+
if (this.status.textContent !== status) this.status.textContent = status;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { Container, Graphics } from 'pixi.js';
|
|
2
|
+
|
|
3
|
+
const WALKER_SHIRTS = [0xd7dee5, 0x345b78, 0xb65a43, 0x70937d, 0xc4a564, 0x5c617d];
|
|
4
|
+
const WALKER_SKIN = [0xefc7a5, 0xd9a17b, 0xa56b48, 0x754b37];
|
|
5
|
+
|
|
6
|
+
// Cosmetic variation has its own generator. It never consumes race randomness.
|
|
7
|
+
function crowdRandom(cars, seed = 2166136261) {
|
|
8
|
+
for (const car of cars) {
|
|
9
|
+
for (const letter of String(car.id)) seed = Math.imul(seed ^ letter.charCodeAt(0), 16777619);
|
|
10
|
+
}
|
|
11
|
+
return () => {
|
|
12
|
+
seed = Math.imul(seed ^ (seed >>> 16), 2246822507);
|
|
13
|
+
seed = Math.imul(seed ^ (seed >>> 13), 3266489909);
|
|
14
|
+
return ((seed ^= seed >>> 16) >>> 0) / 4294967296;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function createWalker(random) {
|
|
19
|
+
const person = new Container();
|
|
20
|
+
const shirt = WALKER_SHIRTS[Math.floor(random() * WALKER_SHIRTS.length)];
|
|
21
|
+
const skin = WALKER_SKIN[Math.floor(random() * WALKER_SKIN.length)];
|
|
22
|
+
const feet = [-3, 3].map((y) => {
|
|
23
|
+
const foot = new Graphics().roundRect(-6, -1.5, 7, 3, 1).fill(0x202932);
|
|
24
|
+
foot.y = y;
|
|
25
|
+
person.addChild(foot);
|
|
26
|
+
return foot;
|
|
27
|
+
});
|
|
28
|
+
const arms = [-6, 6].map((y) => {
|
|
29
|
+
const arm = new Graphics().roundRect(-4, -1.5, 8, 3, 1.5).fill(skin);
|
|
30
|
+
arm.y = y;
|
|
31
|
+
person.addChild(arm);
|
|
32
|
+
return arm;
|
|
33
|
+
});
|
|
34
|
+
person.addChild(new Graphics()
|
|
35
|
+
.ellipse(-1, 1, 9, 7).fill({ color: 0x10151a, alpha: 0.12 })
|
|
36
|
+
.roundRect(-5, -5, 10, 10, 3).fill(shirt)
|
|
37
|
+
.circle(4, 0, 3.2).fill(skin)
|
|
38
|
+
.arc(4, 0, 3.2, -Math.PI / 2, Math.PI / 2).stroke({ color: 0x473b33, width: 1.5 }));
|
|
39
|
+
return { person, feet, arms };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function walkingRoute(cars, offset) {
|
|
43
|
+
const points = cars.map((car) => ({
|
|
44
|
+
x: car.x - Math.sin(car.heading) * (offset - (car.gridOffset ?? 0)),
|
|
45
|
+
y: car.y + Math.cos(car.heading) * (offset - (car.gridOffset ?? 0)),
|
|
46
|
+
}));
|
|
47
|
+
const coreLength = points.slice(1).reduce((length, point, index) =>
|
|
48
|
+
length + Math.hypot(point.x - points[index].x, point.y - points[index].y), 0);
|
|
49
|
+
return { points, coreLength, frontHeading: cars[0].heading, rearHeading: cars.at(-1).heading,
|
|
50
|
+
before: 0, after: 0, segments: [], length: 0 };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function extendRoute(route, before, after) {
|
|
54
|
+
// Panning can introduce tiny floating-point differences in inverse projection.
|
|
55
|
+
before = before > route.before + 0.001 ? before : route.before;
|
|
56
|
+
after = after > route.after + 0.001 ? after : route.after;
|
|
57
|
+
if (before === route.before && after === route.after) return 0;
|
|
58
|
+
const first = route.points[0];
|
|
59
|
+
const last = route.points.at(-1);
|
|
60
|
+
const points = [
|
|
61
|
+
{ x: first.x + Math.cos(route.frontHeading) * before,
|
|
62
|
+
y: first.y + Math.sin(route.frontHeading) * before },
|
|
63
|
+
...route.points,
|
|
64
|
+
{ x: last.x - Math.cos(route.rearHeading) * after,
|
|
65
|
+
y: last.y - Math.sin(route.rearHeading) * after },
|
|
66
|
+
];
|
|
67
|
+
const segments = [];
|
|
68
|
+
let length = 0;
|
|
69
|
+
for (let index = 1; index < points.length; index += 1) {
|
|
70
|
+
const start = points[index - 1];
|
|
71
|
+
const dx = points[index].x - start.x;
|
|
72
|
+
const dy = points[index].y - start.y;
|
|
73
|
+
const distance = Math.hypot(dx, dy);
|
|
74
|
+
if (!distance) continue;
|
|
75
|
+
segments.push({ x: start.x, y: start.y, dx: dx / distance, dy: dy / distance,
|
|
76
|
+
start: length, end: length + distance, heading: Math.atan2(dy, dx) });
|
|
77
|
+
length += distance;
|
|
78
|
+
}
|
|
79
|
+
const shift = before - route.before;
|
|
80
|
+
Object.assign(route, { before, after, segments, length });
|
|
81
|
+
return shift;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function createMechanics(cars, people) {
|
|
85
|
+
const random = crowdRandom(cars, 0x6d656368);
|
|
86
|
+
const teams = [];
|
|
87
|
+
const slots = [[-25, -23], [24, -24], [-27, 24], [25, 24], [0, -27], [0, 28]];
|
|
88
|
+
for (const car of cars) {
|
|
89
|
+
const team = new Container();
|
|
90
|
+
team.position.set(car.x, car.y);
|
|
91
|
+
team.rotation = car.heading;
|
|
92
|
+
// Shuffle working positions so teams differ in both size and arrangement.
|
|
93
|
+
const positions = slots.map((slot) => [...slot]);
|
|
94
|
+
for (let index = positions.length - 1; index > 0; index -= 1) {
|
|
95
|
+
const other = Math.floor(random() * (index + 1));
|
|
96
|
+
[positions[index], positions[other]] = [positions[other], positions[index]];
|
|
97
|
+
}
|
|
98
|
+
const count = 2 + Math.floor(random() * 4);
|
|
99
|
+
for (const [slotX, slotY] of positions.slice(0, count)) {
|
|
100
|
+
const x = slotX + (random() - 0.5) * 5;
|
|
101
|
+
const y = slotY + (random() - 0.5) * 3;
|
|
102
|
+
const kneeling = random() < 0.4;
|
|
103
|
+
const working = random() < 0.7;
|
|
104
|
+
const person = new Graphics()
|
|
105
|
+
.roundRect(-5, -4, kneeling ? 8 : 10, 8, 2).fill(0xd9dfe6)
|
|
106
|
+
.circle(kneeling ? 2 : 4, 0, 3).fill(0xf0cbb1)
|
|
107
|
+
.moveTo(0, -4).lineTo(working ? 6 : -3, -7)
|
|
108
|
+
.moveTo(0, 4).lineTo(working ? 6 : -3, 7)
|
|
109
|
+
.stroke({ color: 0x9aa9b8, width: 2 });
|
|
110
|
+
person.position.set(x, y);
|
|
111
|
+
person.rotation = (y < 0 ? Math.PI / 2 : -Math.PI / 2) + (random() - 0.5) * 0.45;
|
|
112
|
+
people.push({ person, x, y, phase: random() * Math.PI * 2,
|
|
113
|
+
amplitude: working ? (kneeling ? 0.45 : 1.2) : 0.2, cadence: 500 + random() * 450 });
|
|
114
|
+
team.addChild(person);
|
|
115
|
+
}
|
|
116
|
+
const equipmentX = random() < 0.5 ? -36 : 22;
|
|
117
|
+
team.addChild(new Graphics().roundRect(equipmentX, -39, 18, 8, 2).fill(0x39444f)
|
|
118
|
+
.circle(equipmentX + 4, -30, 2).circle(equipmentX + 14, -30, 2).fill(0x10151a));
|
|
119
|
+
teams.push(team);
|
|
120
|
+
}
|
|
121
|
+
return teams;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export class GridWalkCrew {
|
|
125
|
+
constructor(worldLayer, cars) {
|
|
126
|
+
this.container = new Container();
|
|
127
|
+
this.container.eventMode = 'none';
|
|
128
|
+
this.people = [];
|
|
129
|
+
this.walkers = [];
|
|
130
|
+
this.lastElapsed = null;
|
|
131
|
+
this.ambientElapsed = 0;
|
|
132
|
+
this.heading = cars[0]?.heading ?? 0;
|
|
133
|
+
for (const team of createMechanics(cars, this.people)) this.container.addChild(team);
|
|
134
|
+
this.lanes = [];
|
|
135
|
+
if (cars.length) {
|
|
136
|
+
const random = crowdRandom(cars);
|
|
137
|
+
for (const side of [-1, 1]) {
|
|
138
|
+
const route = walkingRoute(cars, side * 95);
|
|
139
|
+
// Keep a modest density even for unusually long host-supplied grids.
|
|
140
|
+
const spacing = Math.max(150, route.coreLength / 32);
|
|
141
|
+
const lane = { route, side, spacing, speed: 16 + random() * 3,
|
|
142
|
+
phase: random() * spacing, walkers: [], random };
|
|
143
|
+
this.lanes.push(lane);
|
|
144
|
+
this.extendLane(lane, spacing, spacing);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
worldLayer.addChild(this.container);
|
|
148
|
+
this.render({ active: true, motion: false, elapsed: 0, clearing: false, clearProgress: 0 });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
updateCamera(frame, width, height) {
|
|
152
|
+
const cos = Math.cos(frame.rotation);
|
|
153
|
+
const sin = Math.sin(frame.rotation);
|
|
154
|
+
const along = { x: Math.cos(this.heading), y: Math.sin(this.heading) };
|
|
155
|
+
const corners = [];
|
|
156
|
+
// Include controls/banner space too: people are still visible behind HTML.
|
|
157
|
+
for (const pan of [frame.minPosition, frame.maxPosition]) {
|
|
158
|
+
const shift = pan - frame.position;
|
|
159
|
+
for (const x of [0, width]) for (const y of [0, height]) {
|
|
160
|
+
const dx = (x - frame.x) / frame.scale;
|
|
161
|
+
const dy = (y - frame.y) / frame.scale;
|
|
162
|
+
corners.push({ x: dx * cos + dy * sin + shift * along.x,
|
|
163
|
+
y: -dx * sin + dy * cos + shift * along.y });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const margin = 14 + 24 / frame.scale;
|
|
167
|
+
for (const lane of this.lanes) {
|
|
168
|
+
const { route } = lane;
|
|
169
|
+
const front = route.points[0];
|
|
170
|
+
const rear = route.points.at(-1);
|
|
171
|
+
const before = Math.max(...corners.map((point) => (point.x - front.x) * Math.cos(route.frontHeading) +
|
|
172
|
+
(point.y - front.y) * Math.sin(route.frontHeading))) + margin;
|
|
173
|
+
const after = Math.max(...corners.map((point) => -(point.x - rear.x) * Math.cos(route.rearHeading) -
|
|
174
|
+
(point.y - rear.y) * Math.sin(route.rearHeading))) + margin;
|
|
175
|
+
this.extendLane(lane, before, after);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
extendLane(lane, before, after) {
|
|
180
|
+
const { route, spacing, random, side } = lane;
|
|
181
|
+
const leading = Math.ceil(Math.max(before, route.before) / spacing - 1e-10) * spacing;
|
|
182
|
+
const count = Math.ceil((route.coreLength + leading + Math.max(after, route.after)) / spacing - 1e-10);
|
|
183
|
+
const trailing = count * spacing - route.coreLength - leading;
|
|
184
|
+
// Whole traffic slots extend the route without changing its moving pattern.
|
|
185
|
+
const shift = extendRoute(route, leading, trailing);
|
|
186
|
+
if (!shift && lane.walkers.length === count) return;
|
|
187
|
+
const occupied = new Set();
|
|
188
|
+
for (const walker of lane.walkers) {
|
|
189
|
+
walker.distance += shift;
|
|
190
|
+
// A person just across the old wrap seam still keeps their world pose.
|
|
191
|
+
// Locate occupied slots from current positions rather than their birth order.
|
|
192
|
+
occupied.add((Math.round((walker.distance - lane.phase) / spacing) + count) % count);
|
|
193
|
+
}
|
|
194
|
+
const newSlots = [];
|
|
195
|
+
for (let slot = 0; slot < count; slot += 1) if (!occupied.has(slot)) newSlots.push(slot);
|
|
196
|
+
for (const slot of newSlots) {
|
|
197
|
+
const phase = random() * Math.PI * 2;
|
|
198
|
+
const cadence = 3000 + random() * 5000;
|
|
199
|
+
const amplitude = 12 + random() * 8;
|
|
200
|
+
const wobble = Math.sin(this.ambientElapsed / cadence + phase) * amplitude;
|
|
201
|
+
// Broad offsets hide the coverage cells: neighbors can walk close together
|
|
202
|
+
// or farther apart, while bounded pace changes prevent long empty stretches.
|
|
203
|
+
const distance = slot * spacing + lane.phase + (random() - 0.5) * spacing * 0.56 + wobble;
|
|
204
|
+
const walker = { ...createWalker(random), route, side, phase, cadence, amplitude, wobble,
|
|
205
|
+
lane, lateral: (random() - 0.5) * 30, distance: (distance + route.length) % route.length };
|
|
206
|
+
lane.walkers.push(walker);
|
|
207
|
+
this.walkers.push(walker);
|
|
208
|
+
this.container.addChild(walker.person);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
render({ active, motion, elapsed, clearing, clearProgress }) {
|
|
213
|
+
const delta = active && motion && this.lastElapsed !== null ? Math.max(0, elapsed - this.lastElapsed) : 0;
|
|
214
|
+
this.lastElapsed = elapsed;
|
|
215
|
+
this.container.visible = active;
|
|
216
|
+
if (!active) return;
|
|
217
|
+
this.ambientElapsed += delta;
|
|
218
|
+
this.container.alpha = clearing ? 1 - clearProgress : 1;
|
|
219
|
+
for (const { person, x, y, phase, amplitude, cadence } of this.people) {
|
|
220
|
+
person.position.set(x + Math.sin(this.ambientElapsed / cadence + phase) * amplitude,
|
|
221
|
+
y + (clearing ? Math.sign(y) * clearProgress * 100 : 0));
|
|
222
|
+
}
|
|
223
|
+
for (const lane of this.lanes) lane.phase = (lane.phase + delta / 1000 * lane.speed) % lane.spacing;
|
|
224
|
+
for (const walker of this.walkers) {
|
|
225
|
+
const { person, route, side, feet, arms, phase, lane, cadence, amplitude } = walker;
|
|
226
|
+
// Individual speed variation is bounded in space, so traffic cannot slowly
|
|
227
|
+
// collapse into groups followed by long empty stretches after repeated laps.
|
|
228
|
+
const wobble = Math.sin(this.ambientElapsed / cadence + phase) * amplitude;
|
|
229
|
+
walker.distance = (walker.distance + delta / 1000 * lane.speed + wobble - walker.wobble +
|
|
230
|
+
route.length) % route.length;
|
|
231
|
+
walker.wobble = wobble;
|
|
232
|
+
const segment = route.segments.find((part) => walker.distance < part.end) ?? route.segments.at(-1);
|
|
233
|
+
const progress = walker.distance - segment.start;
|
|
234
|
+
const sway = Math.sin(this.ambientElapsed / (cadence * 0.8) + phase) * 3;
|
|
235
|
+
const exit = walker.lateral + sway + (clearing ? side * clearProgress * 100 : 0);
|
|
236
|
+
person.position.set(segment.x + segment.dx * progress + segment.dy * exit,
|
|
237
|
+
segment.y + segment.dy * progress - segment.dx * exit);
|
|
238
|
+
person.rotation = segment.heading;
|
|
239
|
+
const stride = Math.sin(this.ambientElapsed / 155 + phase) * 2.4;
|
|
240
|
+
feet[0].x = arms[1].x = stride;
|
|
241
|
+
feet[1].x = arms[0].x = -stride;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
destroy() { this.container.destroy({ children: true }); }
|
|
246
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { projectGridPoint } from './gridWalkCamera.js';
|
|
2
|
+
|
|
3
|
+
const GAP = 8;
|
|
4
|
+
|
|
5
|
+
// Pack a row/column as a group. Independent edge clamping stacks labels at the
|
|
6
|
+
// viewport boundary; packing keeps their order and gives every box real space.
|
|
7
|
+
export function packGridLabels(items, start, end) {
|
|
8
|
+
const ordered = [...items].sort((a, b) => a.anchor - b.anchor);
|
|
9
|
+
if (!ordered.length) return [];
|
|
10
|
+
let cursor = start;
|
|
11
|
+
const packed = ordered.map((item) => {
|
|
12
|
+
const position = Math.max(cursor, Math.min(end - item.size, item.anchor - item.size / 2));
|
|
13
|
+
cursor = position + item.size + GAP;
|
|
14
|
+
return { ...item, position };
|
|
15
|
+
});
|
|
16
|
+
cursor = end;
|
|
17
|
+
for (let index = packed.length - 1; index >= 0; index -= 1) {
|
|
18
|
+
const item = packed[index];
|
|
19
|
+
item.position = Math.min(item.position, cursor - item.size);
|
|
20
|
+
cursor = item.position - GAP;
|
|
21
|
+
}
|
|
22
|
+
return packed;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class GridWalkLabels {
|
|
26
|
+
constructor(element, cars, onSelect, signal) {
|
|
27
|
+
this.element = element;
|
|
28
|
+
this.cars = new Map(cars.map((car) => [car.id, car]));
|
|
29
|
+
this.buttons = [...element.querySelectorAll('[data-grid-entry]')];
|
|
30
|
+
this.lines = new Map([...element.querySelectorAll('[data-grid-connector]')].map((line) => [line.dataset.gridConnector, line]));
|
|
31
|
+
element.querySelector('[data-grid-labels]').addEventListener('click', (event) => {
|
|
32
|
+
const button = event.target.closest('[data-grid-entry]');
|
|
33
|
+
if (button) onSelect(button.dataset.gridEntry);
|
|
34
|
+
}, { signal });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
update(frame, selectedId, fallback) {
|
|
38
|
+
const horizontal = frame.orientation === 'horizontal';
|
|
39
|
+
const { area } = frame;
|
|
40
|
+
const length = horizontal ? area.right - area.left : area.bottom - area.top;
|
|
41
|
+
const middle = horizontal ? (area.top + area.bottom) / 2 : (area.left + area.right) / 2;
|
|
42
|
+
const focused = this.buttons.find((button) => button === this.element.ownerDocument.activeElement);
|
|
43
|
+
const candidates = [];
|
|
44
|
+
for (const button of this.buttons) {
|
|
45
|
+
const id = button.dataset.gridEntry;
|
|
46
|
+
button.hidden = !frame.visibleIds.includes(id);
|
|
47
|
+
button.setAttribute('aria-pressed', String(id === selectedId));
|
|
48
|
+
this.lines.get(id).toggleAttribute('hidden', button.hidden);
|
|
49
|
+
if (!button.hidden) {
|
|
50
|
+
const car = this.cars.get(id);
|
|
51
|
+
const point = projectGridPoint(frame, car);
|
|
52
|
+
candidates.push({ button, id, point, side: car.gridOffset < 0 ? -1 : 1,
|
|
53
|
+
anchor: horizontal ? point.x : point.y });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
for (const side of [-1, 1]) {
|
|
57
|
+
let lane = candidates.filter((item) => item.side === side);
|
|
58
|
+
const width = horizontal ? Math.min(180, Math.max(112, (length - GAP * (lane.length - 1)) / Math.max(1, lane.length)))
|
|
59
|
+
: Math.min(128, (area.right - area.left - GAP) / 2);
|
|
60
|
+
for (const item of lane) {
|
|
61
|
+
item.button.style.width = `${width}px`;
|
|
62
|
+
item.size = horizontal ? width : item.button.offsetHeight;
|
|
63
|
+
}
|
|
64
|
+
// Keep text readable when more cars are visible than the labels can fit.
|
|
65
|
+
// The choice depends on camera geometry, never the selected entry.
|
|
66
|
+
const center = horizontal ? (area.left + area.right) / 2 : (area.top + area.bottom) / 2;
|
|
67
|
+
while (lane.reduce((sum, item) => sum + item.size, 0) + GAP * (lane.length - 1) > length) {
|
|
68
|
+
const farthest = lane.reduce((a, b) => Math.abs(a.anchor - center) > Math.abs(b.anchor - center) ? a : b);
|
|
69
|
+
farthest.button.hidden = true;
|
|
70
|
+
this.lines.get(farthest.id).setAttribute('hidden', '');
|
|
71
|
+
lane = lane.filter((item) => item !== farthest);
|
|
72
|
+
}
|
|
73
|
+
for (const item of packGridLabels(lane, horizontal ? area.left : area.top, horizontal ? area.right : area.bottom)) {
|
|
74
|
+
const height = item.button.offsetHeight;
|
|
75
|
+
const crossSize = horizontal ? height : width;
|
|
76
|
+
const crossStart = horizontal ? area.top : area.left;
|
|
77
|
+
const crossEnd = horizontal ? area.bottom : area.right;
|
|
78
|
+
const minCross = side < 0 ? crossStart : middle + GAP / 2;
|
|
79
|
+
const maxCross = side < 0 ? middle - GAP / 2 : crossEnd;
|
|
80
|
+
if (crossSize > maxCross - minCross) {
|
|
81
|
+
item.button.hidden = true;
|
|
82
|
+
this.lines.get(item.id).setAttribute('hidden', '');
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const desired = (horizontal ? item.point.y : item.point.x) + side * (horizontal ? 55 : 80) - crossSize / 2;
|
|
86
|
+
const cross = Math.max(minCross, Math.min(maxCross - crossSize, desired));
|
|
87
|
+
const x = horizontal ? item.position : cross;
|
|
88
|
+
const y = horizontal ? cross : item.position;
|
|
89
|
+
item.button.style.transform = `translate(${x.toFixed(2)}px, ${y.toFixed(2)}px)`;
|
|
90
|
+
const line = this.lines.get(item.id);
|
|
91
|
+
line.setAttribute('x1', item.point.x.toFixed(2));
|
|
92
|
+
line.setAttribute('y1', item.point.y.toFixed(2));
|
|
93
|
+
line.setAttribute('x2', Math.max(x, Math.min(x + width, item.point.x)).toFixed(2));
|
|
94
|
+
line.setAttribute('y2', Math.max(y, Math.min(y + height, item.point.y)).toFixed(2));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (focused?.hidden) this.focus(selectedId, fallback);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
isVisible(id) { return this.buttons.some((button) => button.dataset.gridEntry === id && !button.hidden); }
|
|
101
|
+
focus(id, fallback) {
|
|
102
|
+
(this.buttons.find((button) => button.dataset.gridEntry === id && !button.hidden) ?? fallback)?.focus({ preventScroll: true });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Owns overlay geometry and restoration of any Race chrome it covers.
|
|
2
|
+
export class GridWalkLayout {
|
|
3
|
+
constructor({ element, panel, canvasHost, root }) {
|
|
4
|
+
Object.assign(this, { element, panel, canvasHost, root });
|
|
5
|
+
this.coveredTowers = new Map();
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
update(active) {
|
|
9
|
+
this.active = active;
|
|
10
|
+
this.syncCoveredTowers();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
measure(bannerSize) {
|
|
14
|
+
const canvas = this.canvasHost.getBoundingClientRect();
|
|
15
|
+
const header = this.element.querySelector('[data-grid-header]').getBoundingClientRect();
|
|
16
|
+
return { left: 16, right: canvas.width - 16,
|
|
17
|
+
top: Math.max(16, header.bottom - canvas.top + 12), bottom: canvas.height - 16 - bannerSize };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
syncCoveredTowers() {
|
|
21
|
+
const canvas = this.canvasHost.getBoundingClientRect();
|
|
22
|
+
for (const tower of this.root.querySelectorAll('[data-timing-tower]')) {
|
|
23
|
+
const rect = tower.getBoundingClientRect();
|
|
24
|
+
const covered = this.active && rect.width > 0 && rect.height > 0 &&
|
|
25
|
+
rect.right > canvas.left && rect.left < canvas.right && rect.bottom > canvas.top && rect.top < canvas.bottom;
|
|
26
|
+
if (covered && !this.coveredTowers.has(tower)) {
|
|
27
|
+
this.coveredTowers.set(tower, tower.hasAttribute('inert'));
|
|
28
|
+
tower.classList.add('is-grid-walk-covered');
|
|
29
|
+
tower.setAttribute('inert', '');
|
|
30
|
+
} else if (!covered && this.coveredTowers.has(tower)) {
|
|
31
|
+
tower.classList.remove('is-grid-walk-covered');
|
|
32
|
+
if (!this.coveredTowers.get(tower)) tower.removeAttribute('inert');
|
|
33
|
+
this.coveredTowers.delete(tower);
|
|
34
|
+
}
|
|
35
|
+
if (covered && !tower.hasAttribute('inert')) tower.setAttribute('inert', '');
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
destroy() {
|
|
40
|
+
for (const [tower, wasInert] of this.coveredTowers) {
|
|
41
|
+
tower.classList.remove('is-grid-walk-covered');
|
|
42
|
+
if (!wasInert) tower.removeAttribute('inert');
|
|
43
|
+
}
|
|
44
|
+
this.coveredTowers.clear();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Every input becomes a displacement on the camera's one permitted screen axis.
|
|
2
|
+
export class GridWalkNavigation {
|
|
3
|
+
constructor({ host, surface = host, isEnabled, getOrientation, onPan, onHome, onSelect }, signal) {
|
|
4
|
+
this.host = host;
|
|
5
|
+
this.surface = surface;
|
|
6
|
+
this.isEnabled = isEnabled;
|
|
7
|
+
this.getOrientation = getOrientation;
|
|
8
|
+
this.onPan = onPan;
|
|
9
|
+
this.canvas = host.querySelector('canvas');
|
|
10
|
+
this.originalAttributes = new Map(['tabindex', 'aria-label'].map((name) => [name, this.canvas.getAttribute(name)]));
|
|
11
|
+
const options = { signal, capture: true };
|
|
12
|
+
const isSceneTarget = (event) => event.target === host || host.contains?.(event.target) ||
|
|
13
|
+
Boolean(event.target.closest?.('[data-grid-entry]'));
|
|
14
|
+
surface.addEventListener('pointerdown', (event) => {
|
|
15
|
+
if (!this.enabled || !isSceneTarget(event) || event.button !== 0 || this.gesture) return;
|
|
16
|
+
this.suppressClick = false;
|
|
17
|
+
this.gesture = { id: event.pointerId, x: event.clientX, y: event.clientY, moved: false,
|
|
18
|
+
entry: event.target.closest?.('[data-grid-entry]')?.dataset.gridEntry ?? null };
|
|
19
|
+
surface.setPointerCapture(event.pointerId);
|
|
20
|
+
}, options);
|
|
21
|
+
surface.addEventListener('pointermove', (event) => {
|
|
22
|
+
const gesture = this.gesture;
|
|
23
|
+
if (!gesture || gesture.id !== event.pointerId) return;
|
|
24
|
+
const delta = this.horizontal ? event.clientX - gesture.x : event.clientY - gesture.y;
|
|
25
|
+
if (!gesture.moved && Math.abs(delta) < 6) return;
|
|
26
|
+
if (!delta) return;
|
|
27
|
+
gesture.moved = true;
|
|
28
|
+
host.dataset.gridDragging = 'true';
|
|
29
|
+
this.onPan(delta);
|
|
30
|
+
gesture.x = event.clientX;
|
|
31
|
+
gesture.y = event.clientY;
|
|
32
|
+
}, options);
|
|
33
|
+
surface.addEventListener('pointerup', (event) => {
|
|
34
|
+
const gesture = this.gesture;
|
|
35
|
+
if (!gesture || gesture.id !== event.pointerId) return;
|
|
36
|
+
this.cancel();
|
|
37
|
+
this.suppressClick = true;
|
|
38
|
+
if (!gesture.moved && gesture.entry && this.enabled) onSelect(gesture.entry);
|
|
39
|
+
}, options);
|
|
40
|
+
surface.addEventListener('click', (event) => {
|
|
41
|
+
if (!this.suppressClick) return;
|
|
42
|
+
this.suppressClick = false;
|
|
43
|
+
if (!this.enabled || !event.detail || (!isSceneTarget(event) && event.target !== surface)) return;
|
|
44
|
+
event.preventDefault();
|
|
45
|
+
event.stopImmediatePropagation();
|
|
46
|
+
}, options);
|
|
47
|
+
for (const name of ['pointercancel', 'lostpointercapture']) surface.addEventListener(name, () => this.cancel(), options);
|
|
48
|
+
surface.addEventListener('wheel', (event) => {
|
|
49
|
+
if (!this.enabled || !isSceneTarget(event) || !event.cancelable || event.defaultPrevented || event.ctrlKey || event.metaKey) return;
|
|
50
|
+
const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? (this.horizontal ? host.clientWidth : host.clientHeight) : 1;
|
|
51
|
+
// A wheel travels front to back in both compositions. A trackpad's
|
|
52
|
+
// horizontal gesture is accepted only by a horizontal camera.
|
|
53
|
+
const delta = this.horizontal ? Math.abs(event.deltaX) > Math.abs(event.deltaY) ? -event.deltaX : event.deltaY : -event.deltaY;
|
|
54
|
+
if (!Number.isFinite(delta) || !delta || !this.onPan(delta * unit)) return;
|
|
55
|
+
event.preventDefault();
|
|
56
|
+
event.stopImmediatePropagation();
|
|
57
|
+
}, { ...options, passive: false });
|
|
58
|
+
surface.addEventListener('keydown', (event) => {
|
|
59
|
+
if (!this.enabled || !isSceneTarget(event) || event.defaultPrevented || event.ctrlKey || event.metaKey || event.altKey) return;
|
|
60
|
+
const keys = this.horizontal ? ['ArrowLeft', 'ArrowRight'] : ['ArrowUp', 'ArrowDown'];
|
|
61
|
+
if (event.key !== 'Home' && !keys.includes(event.key)) return;
|
|
62
|
+
event.preventDefault();
|
|
63
|
+
event.stopImmediatePropagation();
|
|
64
|
+
if (event.key === 'Home') onHome();
|
|
65
|
+
else this.onPan(event.key === keys[0] ? 80 : -80);
|
|
66
|
+
}, options);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
get enabled() { return this.isEnabled(); }
|
|
70
|
+
get horizontal() { return this.getOrientation() === 'horizontal'; }
|
|
71
|
+
|
|
72
|
+
selectOnRelease(id, event) {
|
|
73
|
+
event?.preventDefault?.();
|
|
74
|
+
if (this.gesture) this.gesture.entry = id;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
sync() {
|
|
78
|
+
for (const [name, original] of this.originalAttributes) {
|
|
79
|
+
const value = this.enabled ? name === 'tabindex' ? '0' : 'Starting grid. Drag, scroll or use arrow keys along the grid. Home returns to the selected entry.' : original;
|
|
80
|
+
if (value == null) this.canvas.removeAttribute(name);
|
|
81
|
+
else this.canvas.setAttribute(name, value);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
destroy() {
|
|
86
|
+
this.cancel();
|
|
87
|
+
for (const [name, original] of this.originalAttributes) {
|
|
88
|
+
if (original == null) this.canvas.removeAttribute(name);
|
|
89
|
+
else this.canvas.setAttribute(name, original);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
cancel() {
|
|
94
|
+
const id = this.gesture?.id;
|
|
95
|
+
this.gesture = null;
|
|
96
|
+
delete this.host.dataset.gridDragging;
|
|
97
|
+
if (id != null && this.surface.hasPointerCapture(id)) this.surface.releasePointerCapture(id);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { queryRaceDataPanelBindings } from '../domBindings.js';
|
|
2
|
+
import { renderRaceData } from '../banners/raceDataBannerController.js';
|
|
3
|
+
|
|
4
|
+
// Grid Walk composes the real Race project banner, including its sizing,
|
|
5
|
+
// enabled/muted policy and presenter. Only its navigation action is an anchor.
|
|
6
|
+
export class GridWalkProjectBanner {
|
|
7
|
+
constructor(element, onClose, signal) {
|
|
8
|
+
this.bindings = queryRaceDataPanelBindings(element);
|
|
9
|
+
this.element = this.bindings.raceDataPanel;
|
|
10
|
+
this.element.setAttribute('data-grid-project-banner', '');
|
|
11
|
+
this.heading = this.bindings.raceDataTitle;
|
|
12
|
+
this.heading.setAttribute('data-grid-heading', '');
|
|
13
|
+
this.heading.setAttribute('tabindex', '-1');
|
|
14
|
+
this.open = true;
|
|
15
|
+
this.element.querySelector('[data-race-data-dismiss]').addEventListener('click', onClose, { signal });
|
|
16
|
+
element.addEventListener('keydown', (event) => {
|
|
17
|
+
if (event.key === 'Escape' && !this.element.hidden) { event.preventDefault(); onClose(); }
|
|
18
|
+
}, { signal });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
update({ active, clearing, enabled, car, drivers, options, presentation }) {
|
|
22
|
+
enabled = enabled && options.ui?.showRaceDataPanel !== false;
|
|
23
|
+
this.enabled = enabled;
|
|
24
|
+
this.element.hidden = !this.open || !active || clearing || !enabled;
|
|
25
|
+
if (!enabled) return;
|
|
26
|
+
renderRaceData({ car, drivers, options, presentation, readouts: this.bindings });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
measure(width, height) {
|
|
30
|
+
if (!this.enabled) return 0;
|
|
31
|
+
this.element.classList.toggle('race-data-panel--standalone', width < 600);
|
|
32
|
+
this.element.style.maxHeight = `${Math.max(72, height * 0.34)}px`;
|
|
33
|
+
// Reserve the same scene space after dismissal: closing a project must not
|
|
34
|
+
// zoom or shift the grid under the newly restored focus.
|
|
35
|
+
const hidden = this.element.hidden;
|
|
36
|
+
this.element.hidden = false;
|
|
37
|
+
const size = this.element.getBoundingClientRect().height;
|
|
38
|
+
this.element.hidden = hidden;
|
|
39
|
+
return size + 24;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
focus() { if (!this.element.hidden) this.heading.focus({ preventScroll: true }); }
|
|
43
|
+
}
|