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
3 changes: 3 additions & 0 deletions src/components/realm/RealmMapScreen.css
Original file line number Diff line number Diff line change
Expand Up @@ -700,6 +700,9 @@
box-shadow:
0 0.2rem 0.5rem rgb(0 0 0 / 38%),
0 0 0 1px rgb(74 38 95 / 58%);
transform: scale(var(--realm-resource-presence-scale, 1));
transform-origin: 50% 100%;
transition: transform 180ms cubic-bezier(0.22, 1, 0.36, 1);
}

.realm-resource-occupant-presence--reserved {
Expand Down
6 changes: 6 additions & 0 deletions src/components/realm/RealmMapScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2109,6 +2109,12 @@ function CanonicalRealmMapScreen(props: RealmMapScreenProps) {
}
element.style.setProperty('--realm-resource-marker-x', `${marker.x}px`);
element.style.setProperty('--realm-resource-marker-y', `${marker.y}px`);
if (element.dataset.resourceOccupantLane === 'presence') {
element.style.setProperty(
'--realm-resource-presence-scale',
marker.presenceScale.toFixed(3)
);
}
element.dataset.projectedVisible = 'true';
}
}, []);
Expand Down
137 changes: 128 additions & 9 deletions src/components/realm/realmWorldPortraitLayout.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { RealmLabelReservedRect } from './realmCastlePresentation';
import type { RealmCameraMode } from './realmCameraController';
import {
MAX_RESOURCE_OCCUPANT_ASSIGNMENTS,
MAX_VISIBLE_RESOURCE_OCCUPANT_MARKERS,
realmResourceOccupantMarkerKey,
visibleRealmResourceOccupantPresenceKeys,
Expand All @@ -16,6 +17,9 @@ type PortraitLane = 'worker' | 'resource';

/** Matches the existing bounded route/worker presentation ceiling. */
export const MAX_VISIBLE_REALM_WORKER_PORTRAITS = 24;
export const MIN_REALM_RESOURCE_PRESENCE_SCALE = 0.58;
const REALM_RESOURCE_PRESENCE_NEIGHBOR_RADIUS_PX = 64;
const REALM_RESOURCE_PRESENCE_CROWDING_STRENGTH = 0.5;

type PortraitCandidate = Readonly<{
lane: PortraitLane;
Expand All @@ -35,6 +39,7 @@ export type RealmWorldResourcePortraitProjection = Readonly<{
key: string;
x: number;
y: number;
presenceScale: number;
}>;

export type RealmWorldPortraitLayout = Readonly<{
Expand Down Expand Up @@ -68,6 +73,7 @@ type ResourceProjection = RealmResourceProjectionFrame['markers'][number];
type WorkerProjection = RealmWorkerProjectionFrame['markers'][number];

type ResourceProjectionCache = Readonly<{
overview: boolean;
occupantsByKey: ReadonlyMap<string, RealmResourceOccupantMarker>;
projectedResources: ReadonlyMap<string, ResourceProjection>;
resourceProjections: readonly RealmWorldResourcePortraitProjection[];
Expand Down Expand Up @@ -142,6 +148,80 @@ function frameIsFinite(frame: Readonly<{ width: number; height: number }>) {
&& frame.height > 0;
}

type RealmResourcePresencePoint = Readonly<{
key: string;
x: number;
y: number;
}>;

/**
* Produces a stable local-crowding scale with a bounded spatial hash. Isolated
* portraits remain full size; nearby portraits reduce continuously without
* changing their screen anchor or the interactive control lane.
*/
export function realmResourcePresenceScales(
points: readonly RealmResourcePresencePoint[]
): ReadonlyMap<string, number> {
const buckets = new Map<string, number[]>();
const bucketKey = (x: number, y: number) => `${Math.floor(
x / REALM_RESOURCE_PRESENCE_NEIGHBOR_RADIUS_PX
)}:${Math.floor(y / REALM_RESOURCE_PRESENCE_NEIGHBOR_RADIUS_PX)}`;
points.forEach((point, index) => {
if (
point.key.length === 0
|| !Number.isFinite(point.x)
|| !Number.isFinite(point.y)
) return;
const key = bucketKey(point.x, point.y);
const bucket = buckets.get(key);
if (bucket) bucket.push(index);
else buckets.set(key, [index]);
});

const scales = new Map<string, number>();
const radiusSquared = REALM_RESOURCE_PRESENCE_NEIGHBOR_RADIUS_PX ** 2;
points.forEach((point, pointIndex) => {
if (
point.key.length === 0
|| !Number.isFinite(point.x)
|| !Number.isFinite(point.y)
) return;
const bucketX = Math.floor(
point.x / REALM_RESOURCE_PRESENCE_NEIGHBOR_RADIUS_PX
);
const bucketY = Math.floor(
point.y / REALM_RESOURCE_PRESENCE_NEIGHBOR_RADIUS_PX
);
let crowding = 0;
for (let offsetX = -1; offsetX <= 1; offsetX += 1) {
for (let offsetY = -1; offsetY <= 1; offsetY += 1) {
const neighbors = buckets.get(`${bucketX + offsetX}:${bucketY + offsetY}`)
?? [];
for (const neighborIndex of neighbors) {
if (neighborIndex === pointIndex) continue;
const neighbor = points[neighborIndex];
if (!neighbor) continue;
const deltaX = point.x - neighbor.x;
const deltaY = point.y - neighbor.y;
const distanceSquared = deltaX ** 2 + deltaY ** 2;
if (distanceSquared >= radiusSquared) continue;
crowding += 1 - (
Math.sqrt(distanceSquared)
/ REALM_RESOURCE_PRESENCE_NEIGHBOR_RADIUS_PX
);
}
}
}
scales.set(point.key, Math.max(
MIN_REALM_RESOURCE_PRESENCE_SCALE,
1 / Math.sqrt(
1 + crowding * REALM_RESOURCE_PRESENCE_CROWDING_STRENGTH
)
));
});
return scales;
}

function projectionIsFinite(
projection: Readonly<{
x: number;
Expand Down Expand Up @@ -279,10 +359,14 @@ function sortedProjectionValues<Projection>(

function cachedResourceProjection(
frame: RealmResourceProjectionFrame,
occupantsByKey: ReadonlyMap<string, RealmResourceOccupantMarker>
occupantsByKey: ReadonlyMap<string, RealmResourceOccupantMarker>,
overview: boolean
) {
const cached = resourceProjectionCache.get(frame);
if (cached?.occupantsByKey === occupantsByKey) return cached;
if (
cached?.occupantsByKey === occupantsByKey
&& cached.overview === overview
) return cached;
const projectedResources = frameIsFinite(frame)
? uniqueResourceProjections(frame, occupantsByKey)
: new Map<string, ResourceProjection>();
Expand All @@ -298,11 +382,33 @@ function cachedResourceProjection(
new Set(occupantsByKey.keys())
)
);
const presenceScales = realmResourcePresenceScales(overview
? passivePresenceKeys.flatMap((key) => {
const occupant = occupantsByKey.get(key);
const projection = projectedResources.get(key);
const fitsPassiveGatheringBounds = projection
? portraitRectFitsFrame({
left: projection.x - 24,
top: projection.y - 44,
right: projection.x + 24,
bottom: projection.y + 2
}, frame)
: false;
return occupant?.workerPhase === 'gathering'
&& projection
&& fitsPassiveGatheringBounds
? [{ key, x: projection.x, y: projection.y }]
: [];
})
: []);
const resourceProjections = Object.freeze(sortedResources.map(
(projection): RealmWorldResourcePortraitProjection => Object.freeze({
key: realmResourceOccupantMarkerKey(projection),
x: projection.x,
y: projection.y
y: projection.y,
presenceScale: presenceScales.get(
realmResourceOccupantMarkerKey(projection)
) ?? 1
})
));
const resourceProjectionByKey = new Map(
Expand All @@ -315,6 +421,7 @@ function cachedResourceProjection(
passivePresenceKeys: readonly string[];
}> | undefined;
const result: ResourceProjectionCache = Object.freeze({
overview,
occupantsByKey,
projectedResources,
resourceProjections,
Expand Down Expand Up @@ -402,7 +509,8 @@ export function resolveRealmWorldPortraitLayout(
: new Map<string, WorkerProjection>();
const resourceProjection = cachedResourceProjection(
input.resourceFrame,
occupantsByKey
occupantsByKey,
overview
);
const projectedResources = resourceProjection.projectedResources;
const allResourceCandidates = resourceProjection.getCandidates(
Expand Down Expand Up @@ -493,12 +601,19 @@ export function resolveRealmWorldPortraitLayout(
const acceptedResourceKeySet = new Set(acceptedResourceKeys);
const passiveRects: RealmLabelReservedRect[] = [];
const passiveResourceKeys: string[] = [];
let boundedPassiveCount = 0;
for (const key of resourceCandidates.passivePresenceKeys) {
if (passiveRects.length >= MAX_VISIBLE_RESOURCE_OCCUPANT_MARKERS) break;
if (passiveResourceKeys.length >= MAX_RESOURCE_OCCUPANT_ASSIGNMENTS) break;
if (acceptedResourceKeySet.has(key)) continue;
const projection = projectedResources.get(key);
const occupant = occupantsByKey.get(key);
if (!projection || !occupant) continue;
const persistentOverviewGathering = overview
&& occupant.workerPhase === 'gathering';
if (
!persistentOverviewGathering
&& boundedPassiveCount >= MAX_VISIBLE_RESOURCE_OCCUPANT_MARKERS
) continue;
const reservation = occupant.source === 'generic-worker'
&& occupant.workerPhase === 'outbound';
const horizontalHalf = reservation ? 54 : 24;
Expand All @@ -508,14 +623,18 @@ export function resolveRealmWorldPortraitLayout(
right: projection.x + horizontalHalf,
bottom: projection.y + (reservation ? 8 : 2)
});
if (
!portraitRectFitsFrame(bounds, input.resourceFrame)
|| reservedRects.some((reserved) => portraitRectsIntersect(bounds, reserved))
if (!portraitRectFitsFrame(bounds, input.resourceFrame)) continue;
// Realm overview favors continuity over collision-free decoration for
// active gathering PFPs only. Reservations and close-camera portraits keep
// their bounded collision policy and full-size interaction geometry.
if (!persistentOverviewGathering && (
reservedRects.some((reserved) => portraitRectsIntersect(bounds, reserved))
|| acceptedRects.some((accepted) => portraitRectsIntersect(bounds, accepted))
|| passiveRects.some((accepted) => portraitRectsIntersect(bounds, accepted))
) continue;
)) continue;
passiveRects.push(bounds);
passiveResourceKeys.push(key);
if (!persistentOverviewGathering) boundedPassiveCount += 1;
}

const workerProjections = sortedProjectionValues(projectedWorkers).map(
Expand Down
11 changes: 11 additions & 0 deletions tests/realmCastleCssContract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,10 @@ describe('compact Realm CSS contract', () => {
it('keeps resource occupants camera-stable with passive overflow and accessible controls', () => {
const presenceLayer = block(MAP, '.realm-resource-occupant-presences {');
const presence = block(MAP, '.realm-resource-occupant-presence {');
const presenceAvatar = block(
MAP,
'.realm-resource-occupant-presence .realm-castle-avatar {'
);
const marker = block(MAP, '.realm-resource-occupant-marker {');
const markerLayer = block(MAP, '.realm-resource-occupant-markers {');
const castleLayer = block(PRESENTATION, '.realm-castle-labels {');
Expand All @@ -448,6 +452,13 @@ describe('compact Realm CSS contract', () => {
expect(presence).toContain('cursor: default;');
expect(presence).not.toContain('touch-action: none;');
expect(presence).not.toContain('-webkit-touch-callout: none;');
expect(presenceAvatar).toContain(
'transform: scale(var(--realm-resource-presence-scale, 1));'
);
expect(presenceAvatar).toContain('transform-origin: 50% 100%;');
expect(presenceAvatar).toContain(
'transition: transform 180ms cubic-bezier(0.22, 1, 0.36, 1);'
);
expect(marker).toContain('touch-action: none;');
expect(presence).not.toMatch(/transition:[^;]*transform/);
expect(marker).toContain('--realm-resource-marker-size: 44px;');
Expand Down
91 changes: 91 additions & 0 deletions tests/realmWorldPortraitLayout.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { describe, expect, it } from 'vitest';

import {
MIN_REALM_RESOURCE_PRESENCE_SCALE,
realmResourcePresenceScales,
realmWorldPortraitPriority,
resolveRealmWorldPortraitLayout
} from '../src/components/realm/realmWorldPortraitLayout';
Expand Down Expand Up @@ -256,6 +258,95 @@ describe('Realm world portrait layout', () => {
]);
});

it('retains crowded overview gatherings and scales their passive PFPs smoothly', () => {
const sites = Array.from({ length: 40 }, (_, index) => occupant(index + 1));
const reservation = occupant(1_000, { workerPhase: 'outbound' });
const layout = resolveRealmWorldPortraitLayout({
cameraMode: 'realm',
workers: [],
resourceOccupants: [...sites, reservation],
workerFrame: workerFrame([], 320, 240),
resourceFrame: resourceFrame(
[
...sites.map((site, index) => resourceMarker(
site,
40 + (index % 8) * 34,
72 + Math.floor(index / 8) * 30,
index / 100
)),
resourceMarker(reservation, 160, 120, 0.9)
],
320,
240
),
reservedRects: [{ left: 0, top: 0, right: 320, bottom: 240 }]
});

expect(layout.visibleResourceControlKeys).toEqual([]);
expect(layout.visibleResourcePresenceKeys).toHaveLength(40);
expect(new Set(layout.visibleResourcePresenceKeys).size).toBe(40);
expect(layout.visibleResourcePresenceKeys).not.toContain(
`wood:${reservation.siteId}`
);
expect(layout.suppressedResourceCount).toBe(1);
const scales = layout.resourceProjections
.filter((projection) => projection.key !== `wood:${reservation.siteId}`)
.map((projection) => projection.presenceScale);
expect(scales.every((scale) => scale >= MIN_REALM_RESOURCE_PRESENCE_SCALE))
.toBe(true);
expect(scales.some((scale) => scale < 1)).toBe(true);
});

it('scales local crowding monotonically and deterministically', () => {
const points = [
{ key: 'left', x: 0, y: 0 },
{ key: 'middle', x: 32, y: 0 },
{ key: 'right', x: 64, y: 0 },
{ key: 'isolated', x: 500, y: 500 }
] as const;
const forward = realmResourcePresenceScales(points);
const reversed = realmResourcePresenceScales([...points].reverse());

expect(forward.get('isolated')).toBe(1);
expect(forward.get('middle')).toBeLessThan(forward.get('left')!);
expect(forward.get('middle')).toBeLessThan(forward.get('right')!);
for (const point of points) {
expect(reversed.get(point.key)).toBeCloseTo(forward.get(point.key)!, 12);
}

const stacked = realmResourcePresenceScales(Array.from(
{ length: 20 },
(_, index) => ({ key: `stacked-${index}`, x: 100, y: 100 })
));
expect([...stacked.values()].every((scale) => (
scale === MIN_REALM_RESOURCE_PRESENCE_SCALE
))).toBe(true);
});

it('keeps overview reservations bounded while gathering PFPs stay persistent', () => {
const reservations = Array.from({ length: 30 }, (_, index) => occupant(
index + 1,
{ workerPhase: 'outbound' }
));
const layout = resolveRealmWorldPortraitLayout({
cameraMode: 'realm',
workers: [],
resourceOccupants: reservations,
workerFrame: workerFrame([], 4_000, 240),
resourceFrame: resourceFrame(
reservations.map((site, index) => resourceMarker(site, 80 + index * 120, 120)),
4_000,
240
)
});

expect(layout.visibleResourcePresenceKeys).toHaveLength(24);
expect(layout.suppressedResourceCount).toBe(6);
expect(layout.resourceProjections.every((projection) => (
projection.presenceScale === 1
))).toBe(true);
});

it('rejects overflow using complete control and reservation bounds', () => {
const exactWorker = worker(1);
const clippedWorker = worker(2, { ownedByViewer: false });
Expand Down