diff --git a/src/components/realm/RealmMapScreen.css b/src/components/realm/RealmMapScreen.css index e329fb03..a6daa5f2 100644 --- a/src/components/realm/RealmMapScreen.css +++ b/src/components/realm/RealmMapScreen.css @@ -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 { diff --git a/src/components/realm/RealmMapScreen.tsx b/src/components/realm/RealmMapScreen.tsx index ffc7cb0d..aec43b68 100644 --- a/src/components/realm/RealmMapScreen.tsx +++ b/src/components/realm/RealmMapScreen.tsx @@ -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'; } }, []); diff --git a/src/components/realm/realmWorldPortraitLayout.ts b/src/components/realm/realmWorldPortraitLayout.ts index b9806f4d..262b34bc 100644 --- a/src/components/realm/realmWorldPortraitLayout.ts +++ b/src/components/realm/realmWorldPortraitLayout.ts @@ -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, @@ -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; @@ -35,6 +39,7 @@ export type RealmWorldResourcePortraitProjection = Readonly<{ key: string; x: number; y: number; + presenceScale: number; }>; export type RealmWorldPortraitLayout = Readonly<{ @@ -68,6 +73,7 @@ type ResourceProjection = RealmResourceProjectionFrame['markers'][number]; type WorkerProjection = RealmWorkerProjectionFrame['markers'][number]; type ResourceProjectionCache = Readonly<{ + overview: boolean; occupantsByKey: ReadonlyMap; projectedResources: ReadonlyMap; resourceProjections: readonly RealmWorldResourcePortraitProjection[]; @@ -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 { + const buckets = new Map(); + 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(); + 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; @@ -279,10 +359,14 @@ function sortedProjectionValues( function cachedResourceProjection( frame: RealmResourceProjectionFrame, - occupantsByKey: ReadonlyMap + occupantsByKey: ReadonlyMap, + 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(); @@ -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( @@ -315,6 +421,7 @@ function cachedResourceProjection( passivePresenceKeys: readonly string[]; }> | undefined; const result: ResourceProjectionCache = Object.freeze({ + overview, occupantsByKey, projectedResources, resourceProjections, @@ -402,7 +509,8 @@ export function resolveRealmWorldPortraitLayout( : new Map(); const resourceProjection = cachedResourceProjection( input.resourceFrame, - occupantsByKey + occupantsByKey, + overview ); const projectedResources = resourceProjection.projectedResources; const allResourceCandidates = resourceProjection.getCandidates( @@ -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; @@ -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( diff --git a/tests/realmCastleCssContract.test.ts b/tests/realmCastleCssContract.test.ts index ac4bdf5e..f96b1d14 100644 --- a/tests/realmCastleCssContract.test.ts +++ b/tests/realmCastleCssContract.test.ts @@ -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 {'); @@ -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;'); diff --git a/tests/realmWorldPortraitLayout.test.ts b/tests/realmWorldPortraitLayout.test.ts index bc8c2890..e20ad68d 100644 --- a/tests/realmWorldPortraitLayout.test.ts +++ b/tests/realmWorldPortraitLayout.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest'; import { + MIN_REALM_RESOURCE_PRESENCE_SCALE, + realmResourcePresenceScales, realmWorldPortraitPriority, resolveRealmWorldPortraitLayout } from '../src/components/realm/realmWorldPortraitLayout'; @@ -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 });