Places map: city base map, cluster gallery, zoom + render polish

Base map / tiles:
- Switch the base map to the mapchart city-label world map; re-derive its
  Web-Mercator georeferencing (MAP_LON0/1, MAP_IMG_MY0/1) by least-squares
  against the pixel-identical graticule export.
- Add a 1.5x LANCZOS supersample LOD and bump tile quality 90->94 so city
  labels stay crisp when zoomed in (tools/png_to_tiles.py).
- Fill the map view background with the ocean colour instead of black.

Navigation / clustering:
- Snap the cluster grid to power-of-two zoom octaves so the bubbles stop
  jittering during a continuous zoom.
- Replace the loose zoom clamps with named MAP_SCALE_MIN/MAX (whole map fills
  the screen .. country/region level).
- Add a toggleable computed lat/lon graticule overlay (TRIANGLE).

Cluster gallery:
- Selecting a bubble now opens all its photos on the same justified grid as
  the timeline/search (a g_gallery_active filter). Members not already paged
  in are appended as hidden g_asset entries (kept out of the main timeline);
  a background metadata worker resolves each one's date/aspect so the grid
  reflows into month sections + true aspect ratios.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uKJQMYDE9Nrq9ECsU3MxH
This commit is contained in:
Sads
2026-06-18 22:26:59 +02:00
co-authored by Claude Opus 4.8
parent 02f428a76d
commit 5a0eb06d3e
8 changed files with 408 additions and 59 deletions
+188 -43
View File
@@ -32,17 +32,33 @@ static volatile int g_map_fetching; /* a worker thread is fetching now */
* on-screen pixel size (256 << zoom). g_map_view_init is cleared so the
* first time the page opens (and after an account switch) it fits to the
* markers' bounding box instead of showing a stale view. */
/* zoom limits — g_map_scale is the whole world's on-screen width in px.
* MIN stops zoom-out once the map covers the screen with no black bars; the map
* is taller-aspect than the screen, so the poles and a little longitude crop off
* (pan to reach them) rather than leaving margins. MAX is about country/region
* level — ~4x the deepest tile LOD (10496px), so the raster base map stays
* legible rather than blurring into mush as the old deep caps did. */
#define MAP_SCALE_MIN 1200.0
#define MAP_SCALE_MAX 4.0e4
/* the base map's ocean colour — fills the view behind/around the tiles so the
* margins (and any gap while tiles stream in) read as sea, not black */
#define MAP_OCEAN_BG RGBA8(217, 187, 149, 255)
static double g_map_cx = 0.5, g_map_cy = 0.5;
static double g_map_scale = 512.0;
static double g_map_scale = MAP_SCALE_MIN;
static int g_map_view_init;
static int g_map_grid = 0; /* overlay the computed graticule (toggle: TRIANGLE) */
#define MAP_PI 3.14159265358979323846
/* The source map (MapChart_Map (4)) is a clean conformal full-longitude Web
* Mercator: lon -180..180 across its width, latitude band MAP_IMG_MY0..MY1.
* Verified by isolated-island centroids (span 360.0°, x/y scales match). */
#define MAP_LON0 (-180.0) /* longitude at the image's left edge */
#define MAP_LON1 (180.0) /* longitude at the image's right edge */
/* The source map (a mapchart.net world map, city-label variant) is a
* near-conformal full-longitude Web Mercator: lon spans the image width,
* latitude band MAP_IMG_MY0..MY1. These bounds were fit by least-squares against
* the pixel-identical graticule export of the same map (meridian residual
* <0.5px, parallel <2.3px over 7000px) — that sibling shares this one's exact
* crop (verified: 0px phase-correlation shift), so the fit transfers directly.
* The image runs ~0.45° past ±180 each side. */
#define MAP_LON0 (-180.40) /* longitude at the image's left edge */
#define MAP_LON1 (180.50) /* longitude at the image's right edge */
/* lon/lat -> normalised image coords [0,1]. y is standard Web-Mercator (lat
* clamped to the valid band); the image's merc-y bounds are MAP_IMG_MY0/MY1. */
@@ -168,8 +184,8 @@ static void fetch_map_markers(void)
/* keep the centre on the map and the zoom within sane bounds */
static void map_clamp_view(void)
{
if (g_map_scale < 360.0) g_map_scale = 360.0; /* ~whole world */
if (g_map_scale > 2.0e8) g_map_scale = 2.0e8; /* street level */
if (g_map_scale < MAP_SCALE_MIN) g_map_scale = MAP_SCALE_MIN; /* whole world */
if (g_map_scale > MAP_SCALE_MAX) g_map_scale = MAP_SCALE_MAX; /* neighbourhood */
if (g_map_cx < 0.0) g_map_cx = 0.0;
if (g_map_cx > 1.0) g_map_cx = 1.0;
if (g_map_cy < 0.0) g_map_cy = 0.0;
@@ -183,7 +199,7 @@ static void map_fit_view(void)
if (g_map_count <= 0) {
g_map_cx = 0.5;
g_map_cy = 0.5;
g_map_scale = 512.0;
g_map_scale = MAP_SCALE_MIN;
map_clamp_view();
return;
}
@@ -202,9 +218,9 @@ static void map_fit_view(void)
if (h < 1e-9) h = 1e-9;
double sx = SCREEN_W * 0.82 / w, sy = SCREEN_H * 0.82 / h;
g_map_scale = sx < sy ? sx : sy;
/* a single point fits at "infinite" zoom — pull back to a city view */
if (g_map_scale > 4.0e6)
g_map_scale = 4.0e6;
/* a single point fits at "infinite" zoom — pull back to the max zoom */
if (g_map_scale > MAP_SCALE_MAX)
g_map_scale = MAP_SCALE_MAX;
map_clamp_view();
}
@@ -226,13 +242,19 @@ static int g_cl_count;
/* Group markers into bubbles. The grid cells are anchored in MAP (Web-Mercator)
* space, not screen space, so panning leaves each marker in the same cell and
* the bubbles simply translate with the map instead of reshuffling/jumping.
* Cell size tracks zoom (~MAP_CELL px wide at the current scale). */
*
* The cell size is keyed off a scale SNAPPED to the nearest power-of-two octave,
* not the live scale: the live scale changes every frame while zooming, which
* drifts the cell boundaries so markers hop cells and the bubbles jitter. With
* the snap, the grid is fixed across a ~1.4x zoom band and only re-forms once
* per octave, so during a zoom the bubbles just glide/scale with the map. */
static void map_cluster(void)
{
g_cl_count = 0;
if (g_map_scale <= 0.0)
return;
double cellsz = (double)MAP_CELL / g_map_scale; /* merc units per cell */
double qscale = pow(2.0, round(log2(g_map_scale))); /* octave-snapped */
double cellsz = (double)MAP_CELL / qscale; /* merc units per cell */
for (int i = 0; i < g_map_count; i++) {
double mx = merc_x(g_map_lon[i]), my = merc_y(g_map_lat[i]);
float sx, sy;
@@ -314,30 +336,6 @@ static int map_cluster_at(float x, float y)
return -1;
}
/* a tap/X on cluster c should OPEN its photo (rather than zoom in) when it's a
* lone marker, or when its members are effectively on the same spot so zooming
* would never split them (common with photos shot at one place). */
static int map_should_open(int c)
{
if (c < 0 || c >= g_cl_count)
return 0;
if (g_cl_n[c] <= 1)
return 1;
return (g_cl_x1[c] - g_cl_x0[c] < 6.0f &&
g_cl_y1[c] - g_cl_y0[c] < 6.0f);
}
/* recentre on a cluster and zoom in a notch (splits dense bubbles apart) */
static void map_zoom_into(int c)
{
if (c < 0 || c >= g_cl_count)
return;
g_map_cx += (g_cl_x[c] - SCREEN_W / 2.0f) / g_map_scale;
g_map_cy += (g_cl_y[c] - SCREEN_H / 2.0f) / g_map_scale;
g_map_scale *= 2.6;
map_clamp_view();
}
/* resolve a marker's asset to a display slot, opening it in the detail view.
* the map lists the whole server, so the asset is often not among the pages
* fetched for the grid — in that case fetch its metadata, append it to the
@@ -379,6 +377,7 @@ static int map_open_marker(int marker_idx, int *out_sel)
g_asset_rot[idx] = 0;
g_asset_is_video[idx] = !strncmp(tval, "VIDEO", 5);
g_asset_local_backed[idx] = 0;
g_asset_hidden[idx] = 0;
g_thumb[idx] = NULL;
g_thumb_failed[idx] = 0;
g_tex_err[idx][0] = '\0';
@@ -397,11 +396,11 @@ static int map_open_marker(int marker_idx, int *out_sel)
}
/* latitude band of the source image, in normalised Web-Mercator. NOT simply
* image-height/width: MapChart's vertical scale differs slightly from its
* horizontal, so these were fit against the real marker coordinates (so the
* photos land on land, not offset north into the water). ~76.6°N .. -59.9°S. */
#define MAP_IMG_MY0 0.03993 /* top edge (~83.5°N) */
#define MAP_IMG_MY1 0.95395 /* bottom edge (~-83.5°S) */
* image-height/width: the source's vertical scale differs ~0.4% from its
* horizontal, so these were fit against the image's baked-in graticule (the
* equator sits at y=3213 of 6352, ~37px below image centre). ~83.7°N .. -83.3°S. */
#define MAP_IMG_MY0 0.03814 /* top edge (~83.7°N) */
#define MAP_IMG_MY1 0.95125 /* bottom edge (~-83.3°S) */
/* ------------------------------------------------------------------ */
/* raster tile pyramid (app0:maptiles.pak) — the LOD base map. */
@@ -641,6 +640,41 @@ static void map_draw_grid(void)
}
}
/* Lat/lon graticule, projected with merc_x/merc_y + MAP_LON0/1 + MAP_IMG_MY0/1
* (the same maths the markers use), drawn bright over the tiled base map at 10°
* spacing. The current city-label base map has no printed grid, so this is the
* only graticule; against the graticule export it coincided to ~1px, which is
* how the georeferencing was validated. Prime meridian and equator are brighter
* to anchor orientation. Toggle with TRIANGLE. */
static void map_draw_graticule(void)
{
uint32_t reg = RGBA8(225, 45, 45, 140); /* translucent red */
uint32_t axis = RGBA8(255, 90, 90, 230); /* lon 0 / equator */
/* clip the overlay to the base-map rectangle (clamped to the screen) so
* each line spans only where the map is, not the black margins around it */
float lx, ty, rx, by;
map_project(0.0, MAP_IMG_MY0, &lx, &ty); /* image top-left */
map_project(1.0, MAP_IMG_MY1, &rx, &by); /* image bot-right */
if (lx < 0) lx = 0;
if (ty < 0) ty = 0;
if (rx > SCREEN_W) rx = SCREEN_W;
if (by > SCREEN_H) by = SCREEN_H;
for (int lon = -180; lon <= 180 && by > ty; lon += 10) {
float sx, sy;
map_project(merc_x(lon), 0.0, &sx, &sy);
if (sx >= 0 && sx <= SCREEN_W)
vita2d_draw_rectangle(sx, ty, 1, by - ty,
lon == 0 ? axis : reg);
}
for (int lat = -80; lat <= 80 && rx > lx; lat += 10) {
float sx, sy;
map_project(0.0, merc_y(lat), &sx, &sy);
if (sy >= 0 && sy <= SCREEN_H)
vita2d_draw_rectangle(lx, sy, rx - lx, 1,
lat == 0 ? axis : reg);
}
}
/* draw every cluster bubble; `sel` gets a white selection ring */
static void map_draw_clusters(int sel)
{
@@ -664,3 +698,114 @@ static void map_draw_clusters(int sel)
draw_text(x - w / 2.0f, y + 6.0f, white, 0.8f, b);
}
}
/* ------------------------------------------------------------------ */
/* cluster gallery */
/* */
/* Selecting a bubble shows every photo it groups on the SAME grid as */
/* the timeline/search — a filtered view (g_gallery_*). Members already */
/* paged into the library are reused; the rest are appended as hidden */
/* g_asset entries (id + thumb only) whose date/aspect the metadata */
/* worker fills in lazily, so the justified grid sorts into month */
/* sections and true aspect ratios as the data arrives. */
/* ------------------------------------------------------------------ */
/* g_asset index for an id: an existing entry, or a freshly appended hidden one
* (lets the gallery show a marker not yet paged into the library). -1 on OOM. */
static int asset_for_id(const char *id)
{
for (int i = 0; i < g_asset_count; i++)
if (!strcmp(g_asset_ids[i], id))
return i;
if (!grow_assets(g_asset_count + 1))
return -1;
int idx = g_asset_count++;
snprintf(g_asset_ids[idx], sizeof(g_asset_ids[0]), "%s", id);
g_asset_dates[idx][0] = '\0'; /* "" = unresolved; meta_worker fills it */
g_asset_ratio[idx] = 0.0f;
g_asset_rot[idx] = 0;
g_asset_is_video[idx] = 0;
g_asset_local_backed[idx] = 0;
g_asset_hidden[idx] = 1; /* gallery-only: kept out of the timeline */
g_thumb[idx] = NULL;
g_thumb_failed[idx] = 0;
g_tex_err[idx][0] = '\0';
return idx;
}
/* open the gallery for cluster c: collect its members (same merc cell as the
* bubble) into g_gallery_idx, then rebuild the grid to show them */
static void gallery_open(int c)
{
g_gallery_count = 0;
if (c < 0 || c >= g_cl_count || g_map_scale <= 0.0)
return;
double qscale = pow(2.0, round(log2(g_map_scale)));
double cellsz = (double)MAP_CELL / qscale;
int tgx = g_cl_gx[c], tgy = g_cl_gy[c];
for (int i = 0; i < g_map_count; i++) {
double mx = merc_x(g_map_lon[i]), my = merc_y(g_map_lat[i]);
float sx, sy;
map_project(mx, my, &sx, &sy);
if (sx < -90 || sx > SCREEN_W + 90 ||
sy < -90 || sy > SCREEN_H + 90)
continue;
if ((int)(mx / cellsz) != tgx || (int)(my / cellsz) != tgy)
continue;
int idx = asset_for_id(g_map_id[i]);
if (idx < 0 || !grow_gallery(g_gallery_count + 1))
break;
g_gallery_idx[g_gallery_count++] = idx;
}
g_gallery_active = 1;
rebuild_display();
}
static void gallery_close(void)
{
g_gallery_active = 0;
rebuild_display();
}
/* lazy fill: apply a finished metadata result (re-laying out the grid so the
* item drops into its month/aspect), then request the next missing one —
* visible slots first. g_meta_target is a g_asset index, so it stays valid
* across the rebuilds that reorder g_disp. */
static void gallery_meta_pump(int first_vis, int last_vis,
int *sel, float *scroll, float *target)
{
if (g_meta_state == META_DONE) {
int t = g_meta_target;
if (t >= 0 && t < g_asset_count && g_meta_date[0]) {
snprintf(g_asset_dates[t], sizeof(g_asset_dates[0]), "%s",
g_meta_date);
g_asset_ratio[t] = g_meta_ratio;
g_asset_rot[t] = g_meta_rot;
g_asset_is_video[t] = (unsigned char)g_meta_is_video;
} else if (t >= 0 && t < g_asset_count) {
/* fetch failed: stamp a placeholder date so we don't
* spin on it forever (it just won't group by month) */
snprintf(g_asset_dates[t], sizeof(g_asset_dates[0]),
"0000-00-00");
}
g_meta_state = META_IDLE;
rebuild_keep_view(sel, scroll, target);
}
if (g_meta_state != META_IDLE)
return;
int pick = -1;
for (int d = first_vis; pick < 0 && d >= 0 && d <= last_vis &&
d < g_disp_count; d++)
if (g_disp[d].src == SRC_SERVER &&
g_asset_dates[g_disp[d].idx][0] == '\0')
pick = g_disp[d].idx;
for (int k = 0; pick < 0 && k < g_gallery_count; k++)
if (g_asset_dates[g_gallery_idx[k]][0] == '\0')
pick = g_gallery_idx[k];
if (pick < 0)
return;
g_meta_target = pick;
snprintf(g_meta_id, sizeof(g_meta_id), "%s", g_asset_ids[pick]);
__sync_synchronize();
g_meta_state = META_PENDING;
}