Skip to content
6 changes: 6 additions & 0 deletions api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@

from locations.views import (
PopulationCentreMapView,
InitialMapCentreView,
MapCharacterDetailView,
MapViewportView,
MapWorldBoundsView,
Expand Down Expand Up @@ -135,6 +136,11 @@ def to_url(self, value):
PopulationCentreMapView.as_view(),
name="populationcentre-map",
),
path(
"map/initial-centre/",
InitialMapCentreView.as_view(),
name="map-initial-centre",
),
path(
"map/viewport/",
MapViewportView.as_view(),
Expand Down
26 changes: 16 additions & 10 deletions frontend/src/api/map.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,24 @@
// src/api/map.ts
import { apiFetch } from "../utils/api";

interface PopulationCentreListItem {
id: number;
export interface InitialMapCentre {
id: number | null;
name: string | null;
// [minX, minY, maxX, maxY] in raw EPSG:3857 metres - just enough to frame
// the camera on this village; not the full per-village feature payload
// fetchPopulationCentreMap returns (see InitialMapCentreView's docstring).
bbox: [number, number, number, number] | null;
}

type PopulationCentreListResponse =
| { results?: PopulationCentreListItem[] }
| PopulationCentreListItem[];

export async function fetchFirstPopulationCentreId(): Promise<number | null> {
const data = await apiFetch<PopulationCentreListResponse>("/population-centres/");
const list = Array.isArray(data) ? data : (data?.results ?? []);
return list.length > 0 ? list[0].id : null;
// Which village the map's camera should open on - the requesting player's
// linked character's village if they have one, otherwise an arbitrary but
// deterministic fallback (see InitialMapCentreView, locations/views.py).
// Deliberately a separate, lightweight endpoint rather than reusing
// fetchPopulationCentreMap: this only needs to get the camera pointed at the
// right place before useMapViewport (bbox-scoped, polled) takes over as the
// source of truth moments later, once the camera's first move settles.
export function fetchInitialMapCentre(): Promise<InitialMapCentre> {
return apiFetch("/map/initial-centre/");
}

export interface PopulationCentreSummary {
Expand Down
56 changes: 38 additions & 18 deletions frontend/src/components/Map/Map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,12 @@ import {
TOOLTIP_ONLY_SELECTION_OPACITY,
VILLAGE_LABEL_LAYER,
} from "./layers";
import { buildVillageSourceData, type WalkerState } from "./sourceData";
import {
buildCharacterPointFeatures,
buildStaticVillageFeatures,
buildVillageSourceData,
type WalkerState,
} from "./sourceData";
import MapDetailCard from "../MapDetailCard/MapDetailCard";
import CharacterDetail from "../CharacterDetail/CharacterDetail";
import BuildingDetail from "../BuildingDetail/BuildingDetail";
Expand Down Expand Up @@ -212,6 +217,17 @@ export default function PopulationCentreMap({
[features]
);

// Styled buildings/roads/fields/boundaries - everything the map draws
// except characters. Only recomputed when `features` itself changes (each
// ~2s poll), unlike character positions, which the walker loop below
// recomputes on every animation frame - keeping this out of that loop is
// what keeps a village's buildings/roads/fields from being re-styled and
// re-reprojected 60 times a second while nothing about them has changed.
const staticVillageFeatures = useMemo(
() => buildStaticVillageFeatures(features),
[features]
);

// Lets scatterCharacters spread a field_shelter's idle workers across the
// crops Subzone(s) it services instead of clustering them at the
// shelter's own small footprint - see scatterCharacters' own comment.
Expand Down Expand Up @@ -258,16 +274,19 @@ export default function PopulationCentreMap({
const walkersRef = useRef<Map<string, WalkerState>>(new Map());

const refreshVillageSource = useCallback(() => {
sourceRef.current?.setData(
buildVillageSourceData({
features,
characterFeatures,
idleCharacterPositions,
walkers: walkersRef.current,
now: Date.now(),
})
);
}, [features, characterFeatures, idleCharacterPositions]);
sourceRef.current?.setData({
type: "FeatureCollection",
features: [
...staticVillageFeatures,
...buildCharacterPointFeatures({
characterFeatures,
idleCharacterPositions,
walkers: walkersRef.current,
now: Date.now(),
}),
],
});
}, [staticVillageFeatures, characterFeatures, idleCharacterPositions]);

// Creates the map once. onViewportChange and refreshVillageSource are each
// read via a ref inside the handlers below rather than as effect deps, so
Expand Down Expand Up @@ -689,18 +708,19 @@ export default function PopulationCentreMap({
// Each frame recomputes position from scratch - the checkpoint plus how
// much time has passed since it was taken - rather than stepping forward
// from wherever the previous frame left off, so nothing compounds across
// frames or across polls (see the WalkerState comment above).
// frames or across polls (see the WalkerState comment above). Only runs
// while at least one character actually has an active journey - an idle
// village (the common case) has nothing to animate, so there's no reason
// to keep a 60fps timer alive rebuilding the source every 16ms.
useEffect(() => {
const step = () => {
if (mapReady) {
refreshVillageSource();
}
};
if (!mapReady || walkingFeatures.length === 0) return;

const step = () => refreshVillageSource();

step();
const intervalId = window.setInterval(step, 16);
return () => window.clearInterval(intervalId);
}, [mapReady, refreshVillageSource]);
}, [mapReady, walkingFeatures.length, refreshVillageSource]);

// Outlines whichever building/character the detail card currently has
// open (see SELECTED_BUILDING_OUTLINE_LAYER/SELECTED_CHARACTER_HIGHLIGHT_LAYER
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/components/Map/geojson.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ export function polygonTooltipContent(
);
}
if (properties?.feature_type === "subzone") {
if (properties?.usage === "square") return "Square";
if (properties?.usage !== "crops") return properties?.name;

const stage = properties?.crop_stage as string | null | undefined;
Expand All @@ -125,6 +126,12 @@ export function polygonTooltipContent(
return properties?.name;
}

// Open communal outdoor space (see watabou_import._import_squares) - a
// warm, paved tone distinct from both a crops Subzone's green (fieldFillFor)
// and a building's default grey, so a plaza reads as open ground rather
// than a structure.
const SQUARE_FILL_COLOR = "#d8c9a8";

// Precomputes per-feature presentation properties (fill/stroke) so map
// styling can stay simple `["get", ...]` paint expressions instead of
// duplicating fieldFillFor's stage/progress logic as a style expression.
Expand All @@ -135,13 +142,17 @@ export function styledPolygonFeatures(features: GeoJSONFeature[]) {
const isBoundary = f.properties?.feature_type === "boundary";
const isCropSubzone =
f.properties?.feature_type === "subzone" && f.properties?.usage === "crops";
const isSquareSubzone =
f.properties?.feature_type === "subzone" && f.properties?.usage === "square";
const fillColor = isBoundary
? "transparent"
: isCropSubzone
? fieldFillFor(
f.properties?.crop_stage as string | null | undefined,
f.properties?.crop_progress as number | null | undefined
)
: isSquareSubzone
? SQUARE_FILL_COLOR
: "#ddd";
return {
type: "Feature" as const,
Expand Down
57 changes: 45 additions & 12 deletions frontend/src/components/Map/sourceData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,22 +48,37 @@ function positionAlongPath(
return pos;
}

interface BuildVillageSourceDataArgs {
features: GeoJSONFeature[];
// Styled buildings/roads/fields/boundaries - everything except characters.
// This only changes when `features` itself changes (i.e. once per ~2s poll),
// unlike character positions, which are recomputed on every animation frame
// by the walker loop in Map.tsx. Callers should memoize this separately
// (keyed on `features`) rather than folding it into buildVillageSourceData,
// so that per-frame loop isn't re-styling and re-reprojecting every building/
// road/field 60 times a second when only the characters are actually moving.
export function buildStaticVillageFeatures(features: GeoJSONFeature[]) {
return [
...styledPolygonFeatures(features),
...styledLineFeatures(features),
...styledPointFeatures(
features.filter((feature) => feature.properties?.feature_type !== "character")
),
];
}

interface BuildCharacterPointFeaturesArgs {
characterFeatures: GeoJSONFeature[];
idleCharacterPositions: Map<string, [number, number]>;
walkers: Map<string, WalkerState>;
now: number;
}

export function buildVillageSourceData({
features,
export function buildCharacterPointFeatures({
characterFeatures,
idleCharacterPositions,
walkers,
now,
}: BuildVillageSourceDataArgs) {
const characterPointFeatures: LngLatPointFeature[] = characterFeatures.map((feature) => {
}: BuildCharacterPointFeaturesArgs): LngLatPointFeature[] {
return characterFeatures.map((feature) => {
const id = String(feature.properties?.id);
const walker = walkers.get(id);
const rawPoint = walker
Expand All @@ -83,16 +98,34 @@ export function buildVillageSourceData({
properties: feature.properties,
};
});
}

interface BuildVillageSourceDataArgs {
features: GeoJSONFeature[];
characterFeatures: GeoJSONFeature[];
idleCharacterPositions: Map<string, [number, number]>;
walkers: Map<string, WalkerState>;
now: number;
}

// Full rebuild of the source's FeatureCollection - static features plus
// current character positions. Used on mount and whenever `features` itself
// changes; the per-frame walker loop in Map.tsx calls
// buildCharacterPointFeatures directly against a memoized
// buildStaticVillageFeatures result instead, since that loop only ever needs
// to update character positions, not the static geometry around them.
export function buildVillageSourceData({
features,
characterFeatures,
idleCharacterPositions,
walkers,
now,
}: BuildVillageSourceDataArgs) {
return {
type: "FeatureCollection" as const,
features: [
...styledPolygonFeatures(features),
...styledLineFeatures(features),
...styledPointFeatures(
features.filter((feature) => feature.properties?.feature_type !== "character")
),
...characterPointFeatures,
...buildStaticVillageFeatures(features),
...buildCharacterPointFeatures({ characterFeatures, idleCharacterPositions, walkers, now }),
],
};
}
21 changes: 21 additions & 0 deletions frontend/src/context/GameContext.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
// GameContext.tsx
import { useState, useEffect, useCallback, useMemo } from 'react';
import type { ReactElement, ReactNode } from 'react';
import { useQueryClient } from '@tanstack/react-query';

import { useBootstrapGameData } from '../hooks/useBootstrapGameData';
import { useEventCallback } from '../hooks/useEventCallback';
import { apiFetch } from "../utils/api";
import useActivityTimer from '../hooks/useActivityTimer';
import useUnloadWarning from '../hooks/useUnloadWarning';
import { initialMapCentreQueryOptions, mapWorldBoundsQueryOptions } from '../hooks/useMap';
import { useAuth } from './AuthContext';
import { GameContext, type GameContextValue } from './gameContext';
import type {
Expand Down Expand Up @@ -85,6 +87,8 @@ export const GameProvider = ({ children }: ProviderProps): ReactElement => {

useUnloadWarning(activityTimer.status === 'active');

const queryClient = useQueryClient();


// ----------------------------------------
// STABLE CALLBACKS
Expand Down Expand Up @@ -150,6 +154,23 @@ export const GameProvider = ({ children }: ProviderProps): ReactElement => {
player?.is_premium,
]);

// Primes the map's two cheap, one-shot "where/how big is the world"
// queries as soon as fetch_info has resolved, rather than waiting for the
// player to navigate to the map page and pay for them there. Both use the
// same {queryKey, queryFn, staleTime} as useInitialMapCentre/
// useMapWorldBounds (see useMap.ts) so this primes exactly the cache entry
// those hooks read - if the player never opens the map, the prefetched
// data just sits unused until its gcTime expires. Deliberately doesn't
// prefetch /map/viewport/: that needs a bbox only the mounted map
// component can produce, and (unlike these two) starts a 2s poll once
// enabled, which would run in the background for every session whether or
// not the player ever opens the map.
useEffect(() => {
if (loading || !isAuthenticated) return;
void queryClient.prefetchQuery(initialMapCentreQueryOptions);
void queryClient.prefetchQuery(mapWorldBoundsQueryOptions);
}, [loading, isAuthenticated, queryClient]);

const onAuthReadyFetchActivities = useEventCallback(fetchActivities);
useEffect(() => {
if (!authLoading && isAuthenticated) {
Expand Down
Loading
Loading