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
Binary file not shown.
+99
View File
@@ -89,6 +89,7 @@ static int parse_assets(const char *js, size_t jslen, char *err, size_t errlen)
g_asset_ratio[cur] = 0.0f; g_asset_ratio[cur] = 0.0f;
g_asset_rot[cur] = 0; g_asset_rot[cur] = 0;
g_asset_is_video[cur] = 0; g_asset_is_video[cur] = 0;
g_asset_hidden[cur] = 0;
continue; continue;
} }
if (cur < 0 || tok[i].type != JSMN_STRING || tok[i].size != 1 || if (cur < 0 || tok[i].type != JSMN_STRING || tok[i].size != 1 ||
@@ -279,4 +280,102 @@ static int check_new_assets(void)
return fresh; return fresh;
} }
/* parse a single /api/assets/{id} object for the fields the grid needs to lay
* an item out: capture date, display aspect (from exif), orientation, video
* flag. returns 1 if the date was found. (parse_assets handles arrays; this is
* the one-object form used by the gallery's lazy metadata resolver.) */
static int parse_one_asset(const char *js, size_t len, char *date, size_t datelen,
float *ratio, unsigned char *rot, int *is_video)
{
jsmn_parser p;
jsmn_init(&p);
int n = jsmn_parse(&p, js, len, NULL, 0);
if (n <= 0)
return 0;
jsmntok_t *t = malloc(sizeof(*t) * n);
if (!t)
return 0;
jsmn_init(&p);
n = jsmn_parse(&p, js, len, t, n);
if (n <= 0 || t[0].type != JSMN_OBJECT) {
free(t);
return 0;
}
date[0] = '\0';
*ratio = 0.0f;
*rot = 0;
*is_video = 0;
int exif_obj = -1, exw = 0, exh = 0, exori = 0, got = 0;
for (int i = 1; i + 1 < n; i++) {
if (t[i].type != JSMN_STRING || t[i].size != 1)
continue;
int kl = t[i].end - t[i].start;
const char *k = js + t[i].start;
jsmntok_t *v = &t[i + 1];
int vl = v->end - v->start;
if (t[i].parent == 0 && kl == 8 && !strncmp(k, "exifInfo", 8) &&
v->type == JSMN_OBJECT) {
exif_obj = i + 1;
continue;
}
if (t[i].parent == exif_obj) {
if (kl == 14 && !strncmp(k, "exifImageWidth", 14))
exw = atoi(js + v->start);
else if (kl == 15 && !strncmp(k, "exifImageHeight", 15))
exh = atoi(js + v->start);
else if (kl == 11 && !strncmp(k, "orientation", 11))
exori = atoi(js + v->start);
continue;
}
if (t[i].parent != 0)
continue;
if (kl == 13 && !strncmp(k, "fileCreatedAt", 13) && vl >= 10) {
int cl = vl < (int)datelen - 1 ? vl : (int)datelen - 1;
memcpy(date, js + v->start, cl);
date[cl] = '\0';
got = 1;
} else if (kl == 4 && !strncmp(k, "type", 4)) {
*is_video = (vl >= 5 && !strncmp(js + v->start, "VIDEO", 5));
}
}
if (exw > 0 && exh > 0)
*ratio = (exori >= 5 && exori <= 8) ?
(float)exh / exw : (float)exw / exh;
*rot = (unsigned char)exori;
free(t);
return got;
}
/* background metadata resolver thread: fetches one asset's date/aspect at a
* time (the gallery's lazy fill). One-slot handoff like the thumb worker —
* main writes g_meta_id + flips to PENDING, we fetch+parse and flip to DONE. */
static int meta_worker(SceSize args, void *argp)
{
(void)args;
(void)argp;
for (;;) {
if (g_meta_state != META_PENDING) {
sceKernelDelayThread(4000);
continue;
}
char url[600];
snprintf(url, sizeof(url), "%s/api/assets/%s", g_server, g_meta_id);
membuf buf;
long code;
CURLcode r = http_request(url, NULL, &buf, &code, NULL, 0);
g_meta_date[0] = '\0';
g_meta_ratio = 0.0f;
g_meta_rot = 0;
g_meta_is_video = 0;
if (r == CURLE_OK && code >= 200 && code < 300 && buf.data)
parse_one_asset(buf.data, buf.size, g_meta_date,
sizeof(g_meta_date), &g_meta_ratio,
&g_meta_rot, &g_meta_is_video);
free(buf.data);
__sync_synchronize();
g_meta_state = META_DONE;
}
return 0;
}
+50 -7
View File
@@ -112,6 +112,8 @@ static void reset_for_account_change(void)
g_next_page = 1; g_next_page = 1;
g_search_active = 0; g_search_active = 0;
g_search_count = 0; g_search_count = 0;
g_gallery_active = 0;
g_gallery_count = 0;
g_srv_state = 0; /* re-fetch the cloud page's server info */ g_srv_state = 0; /* re-fetch the cloud page's server info */
g_srv_fetching = 0; g_srv_fetching = 0;
g_map_state = 0; /* re-fetch the new account's map markers */ g_map_state = 0; /* re-fetch the new account's map markers */
@@ -272,6 +274,11 @@ int main(void)
if (syncw >= 0) if (syncw >= 0)
sceKernelStartThread(syncw, 0, NULL); sceKernelStartThread(syncw, 0, NULL);
SceUID metaw = sceKernelCreateThread("meta_worker", meta_worker,
0x10000100, 128 * 1024, 0, 0, NULL);
if (metaw >= 0)
sceKernelStartThread(metaw, 0, NULL);
load_autobackup(); load_autobackup();
int mode = MODE_GRID; int mode = MODE_GRID;
@@ -482,7 +489,8 @@ int main(void)
* be fetched yet — pull pages until a new month * be fetched yet — pull pages until a new month
* shows up (or the library ends). search results * shows up (or the library ends). search results
* aren't paginated, so skip the pull. */ * aren't paginated, so skip the pull. */
if (dir > 0 && !g_search_active && can_pull) { if (dir > 0 && !g_search_active &&
!g_gallery_active && can_pull) {
int guard = 0; int guard = 0;
while (g_next_page > 0 && guard++ < 10 && while (g_next_page > 0 && guard++ < 10 &&
!strncmp(disp_date(month_jump(sel, +1)), !strncmp(disp_date(month_jump(sel, +1)),
@@ -540,7 +548,7 @@ int main(void)
* screen: the grid stays up, the HUD's "+" already says * screen: the grid stays up, the HUD's "+" already says
* more is coming, and the fetch only blocks briefly */ * more is coming, and the fetch only blocks briefly */
if (!scrolling_fast && rl_idle >= 12 && !g_search_active && if (!scrolling_fast && rl_idle >= 12 && !g_search_active &&
g_next_page > 0 && !g_gallery_active && g_next_page > 0 &&
sel >= g_disp_count - COLS * 4) { sel >= g_disp_count - COLS * 4) {
if (fetch_page(0) > 0) if (fetch_page(0) > 0)
rebuild_keep_view(&sel, &scroll, rebuild_keep_view(&sel, &scroll,
@@ -553,6 +561,7 @@ int main(void)
* across the relayout */ * across the relayout */
uint64_t pnow = sceKernelGetProcessTimeWide(); uint64_t pnow = sceKernelGetProcessTimeWide();
if (!scrolling_fast && !touch_active && !g_search_active && if (!scrolling_fast && !touch_active && !g_search_active &&
!g_gallery_active &&
pnow - last_poll > 30ULL * 1000 * 1000) { pnow - last_poll > 30ULL * 1000 * 1000) {
last_poll = pnow; last_poll = pnow;
if (check_new_assets() > 0) if (check_new_assets() > 0)
@@ -664,6 +673,21 @@ int main(void)
} }
} }
/* in a cluster gallery, O returns to the map; any
* bar action (search/cloud/map) drops the filter */
if (g_gallery_active && (pressed & SCE_CTRL_CIRCLE)) {
gallery_close();
mode = MODE_MAP;
sel = 0;
scroll = target = 0;
grid_last_sel = -1;
touch_active = touch_on_bar = 0;
continue;
}
if (g_gallery_active &&
(do_open_cloud || do_open_map || do_search_open))
gallery_close();
if (do_open_cloud) { if (do_open_cloud) {
mode = MODE_CLOUD; mode = MODE_CLOUD;
continue; continue;
@@ -825,6 +849,12 @@ int main(void)
last_vis = g_disp_count - 1; last_vis = g_disp_count - 1;
} }
/* cluster gallery: resolve members' date/aspect in the
* background so the grid sorts into months + true aspects */
if (g_gallery_active)
gallery_meta_pump(first_vis, last_vis,
&sel, &scroll, &target);
/* collect finished download, then hand the worker /* collect finished download, then hand the worker
* the next most useful thumbnail */ * the next most useful thumbnail */
if (g_req_state == REQ_DONE) { if (g_req_state == REQ_DONE) {
@@ -1070,7 +1100,8 @@ int main(void)
} }
if (pressed & SCE_CTRL_CIRCLE) { if (pressed & SCE_CTRL_CIRCLE) {
/* back to wherever we came from (the map if the /* back to wherever we came from (the map if the
* photo was opened from a marker, else the grid) */ * photo was opened from a marker, else the grid —
* which may be showing a cluster gallery) */
mode = detail_from_map ? MODE_MAP : MODE_GRID; mode = detail_from_map ? MODE_MAP : MODE_GRID;
detail_from_map = 0; detail_from_map = 0;
detail_idx = -1; detail_idx = -1;
@@ -1831,6 +1862,7 @@ pf_skip:
if (pad.buttons & SCE_CTRL_DOWN) g_map_cy += pstep; if (pad.buttons & SCE_CTRL_DOWN) g_map_cy += pstep;
if (pad.buttons & SCE_CTRL_RTRIGGER) g_map_scale *= 1.03; if (pad.buttons & SCE_CTRL_RTRIGGER) g_map_scale *= 1.03;
if (pad.buttons & SCE_CTRL_LTRIGGER) g_map_scale /= 1.03; if (pad.buttons & SCE_CTRL_LTRIGGER) g_map_scale /= 1.03;
if (pressed & SCE_CTRL_TRIANGLE) g_map_grid = !g_map_grid;
map_clamp_view(); map_clamp_view();
/* touch: one finger drags the map, two fingers pinch-zoom, /* touch: one finger drags the map, two fingers pinch-zoom,
@@ -1904,7 +1936,8 @@ pf_skip:
else if (tapped_cluster >= 0) else if (tapped_cluster >= 0)
act = tapped_cluster; act = tapped_cluster;
if (act >= 0 && act < g_cl_count) { if (act >= 0 && act < g_cl_count) {
if (map_should_open(act)) { if (g_cl_n[act] <= 1) {
/* a lone marker: open the photo directly */
int s; int s;
if (map_open_marker(g_cl_rep[act], &s)) { if (map_open_marker(g_cl_rep[act], &s)) {
sel = s; sel = s;
@@ -1918,15 +1951,25 @@ pf_skip:
grid_last_sel = -1; grid_last_sel = -1;
continue; continue;
} }
map_zoom_into(act); /* couldn't open: just zoom */
} else { } else {
map_zoom_into(act); /* a bubble: show all its photos on the grid */
g_search_active = 0;
gallery_open(act);
sel = 0;
scroll = target = 0.0f;
grid_last_sel = -1;
mode = MODE_GRID;
continue;
} }
} }
vita2d_start_drawing(); vita2d_start_drawing();
vita2d_clear_screen(); vita2d_clear_screen();
vita2d_draw_rectangle(0, 0, SCREEN_W, SCREEN_H,
MAP_OCEAN_BG); /* sea, not black */
map_draw_tiles(); map_draw_tiles();
if (g_map_grid)
map_draw_graticule();
map_draw_clusters(nc); map_draw_clusters(nc);
/* centre crosshair: shows what X will act on */ /* centre crosshair: shows what X will act on */
vita2d_draw_rectangle(SCREEN_W / 2.0f - 8, SCREEN_H / 2.0f, vita2d_draw_rectangle(SCREEN_W / 2.0f - 8, SCREEN_H / 2.0f,
@@ -1940,7 +1983,7 @@ pf_skip:
draw_text(12, 28, RGBA8(235, 235, 240, 255), 1.0f, mh); draw_text(12, 28, RGBA8(235, 235, 240, 255), 1.0f, mh);
draw_text(12, SCREEN_H - 16, draw_text(12, SCREEN_H - 16,
RGBA8(150, 150, 158, 255), 0.85f, RGBA8(150, 150, 158, 255), 0.85f,
"d-pad pan L/R zoom X open/zoom O back"); "d-pad pan L/R zoom X open /\\ grid O back");
} }
vita2d_end_drawing(); vita2d_end_drawing();
vita2d_swap_buffers(); vita2d_swap_buffers();
+20
View File
@@ -176,6 +176,24 @@ static void layout_grid(void)
* server asset is shown once, as the server asset (green badge). */ * server asset is shown once, as the server asset (green badge). */
static void rebuild_display(void) static void rebuild_display(void)
{ {
/* map cluster gallery: show exactly the selected bubble's members (server
* assets), date-grouped like the timeline. Members whose metadata hasn't
* resolved yet have an empty date and sort to the end until it arrives. */
if (g_gallery_active) {
if (!grow_disp(g_gallery_count > 0 ? g_gallery_count : 1))
return;
int n = 0;
for (int k = 0; k < g_gallery_count; k++) {
g_disp[n].src = SRC_SERVER;
g_disp[n].idx = g_gallery_idx[k];
n++;
}
g_disp_count = n;
qsort(g_disp, n, sizeof(g_disp[0]), disp_cmp);
layout_grid();
return;
}
/* search mode: the merged timeline is replaced by the result set /* search mode: the merged timeline is replaced by the result set
* (server assets only, date-grouped); local media isn't folded in */ * (server assets only, date-grouped); local media isn't folded in */
if (g_search_active) { if (g_search_active) {
@@ -201,6 +219,8 @@ static void rebuild_display(void)
int n = 0; int n = 0;
for (int i = 0; i < g_asset_count; i++) { for (int i = 0; i < g_asset_count; i++) {
if (g_asset_hidden[i])
continue; /* gallery-only asset: not in the timeline */
g_disp[n].src = SRC_SERVER; g_disp[n].src = SRC_SERVER;
g_disp[n].idx = i; g_disp[n].idx = i;
n++; n++;
+3
View File
@@ -128,6 +128,9 @@ static unsigned char *g_asset_is_video;
/* set during display rebuild: a backed-up local file matches this server /* set during display rebuild: a backed-up local file matches this server
* asset, so its grid cell shows the green "backed up" badge */ * asset, so its grid cell shows the green "backed up" badge */
static unsigned char *g_asset_local_backed; static unsigned char *g_asset_local_backed;
/* 1 = appended only to back a map-cluster gallery (its id/thumb are needed but
* it isn't part of the paged library): shown in the gallery, hidden elsewhere */
static unsigned char *g_asset_hidden;
static int g_asset_count; static int g_asset_count;
static int g_asset_cap; static int g_asset_cap;
static int g_next_page = 1; /* 0 = no more pages */ static int g_next_page = 1; /* 0 = no more pages */
+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 * 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 * first time the page opens (and after an account switch) it fits to the
* markers' bounding box instead of showing a stale view. */ * 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_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_view_init;
static int g_map_grid = 0; /* overlay the computed graticule (toggle: TRIANGLE) */
#define MAP_PI 3.14159265358979323846 #define MAP_PI 3.14159265358979323846
/* The source map (MapChart_Map (4)) is a clean conformal full-longitude Web /* The source map (a mapchart.net world map, city-label variant) is a
* Mercator: lon -180..180 across its width, latitude band MAP_IMG_MY0..MY1. * near-conformal full-longitude Web Mercator: lon spans the image width,
* Verified by isolated-island centroids (span 360.0°, x/y scales match). */ * latitude band MAP_IMG_MY0..MY1. These bounds were fit by least-squares against
#define MAP_LON0 (-180.0) /* longitude at the image's left edge */ * the pixel-identical graticule export of the same map (meridian residual
#define MAP_LON1 (180.0) /* longitude at the image's right edge */ * <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 /* 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. */ * 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 */ /* keep the centre on the map and the zoom within sane bounds */
static void map_clamp_view(void) static void map_clamp_view(void)
{ {
if (g_map_scale < 360.0) g_map_scale = 360.0; /* ~whole world */ if (g_map_scale < MAP_SCALE_MIN) g_map_scale = MAP_SCALE_MIN; /* whole world */
if (g_map_scale > 2.0e8) g_map_scale = 2.0e8; /* street level */ 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 < 0.0) g_map_cx = 0.0;
if (g_map_cx > 1.0) g_map_cx = 1.0; if (g_map_cx > 1.0) g_map_cx = 1.0;
if (g_map_cy < 0.0) g_map_cy = 0.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) { if (g_map_count <= 0) {
g_map_cx = 0.5; g_map_cx = 0.5;
g_map_cy = 0.5; g_map_cy = 0.5;
g_map_scale = 512.0; g_map_scale = MAP_SCALE_MIN;
map_clamp_view(); map_clamp_view();
return; return;
} }
@@ -202,9 +218,9 @@ static void map_fit_view(void)
if (h < 1e-9) h = 1e-9; if (h < 1e-9) h = 1e-9;
double sx = SCREEN_W * 0.82 / w, sy = SCREEN_H * 0.82 / h; double sx = SCREEN_W * 0.82 / w, sy = SCREEN_H * 0.82 / h;
g_map_scale = sx < sy ? sx : sy; g_map_scale = sx < sy ? sx : sy;
/* a single point fits at "infinite" zoom — pull back to a city view */ /* a single point fits at "infinite" zoom — pull back to the max zoom */
if (g_map_scale > 4.0e6) if (g_map_scale > MAP_SCALE_MAX)
g_map_scale = 4.0e6; g_map_scale = MAP_SCALE_MAX;
map_clamp_view(); 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) /* 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 * 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. * 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) static void map_cluster(void)
{ {
g_cl_count = 0; g_cl_count = 0;
if (g_map_scale <= 0.0) if (g_map_scale <= 0.0)
return; 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++) { for (int i = 0; i < g_map_count; i++) {
double mx = merc_x(g_map_lon[i]), my = merc_y(g_map_lat[i]); double mx = merc_x(g_map_lon[i]), my = merc_y(g_map_lat[i]);
float sx, sy; float sx, sy;
@@ -314,30 +336,6 @@ static int map_cluster_at(float x, float y)
return -1; 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. /* 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 * 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 * 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_rot[idx] = 0;
g_asset_is_video[idx] = !strncmp(tval, "VIDEO", 5); g_asset_is_video[idx] = !strncmp(tval, "VIDEO", 5);
g_asset_local_backed[idx] = 0; g_asset_local_backed[idx] = 0;
g_asset_hidden[idx] = 0;
g_thumb[idx] = NULL; g_thumb[idx] = NULL;
g_thumb_failed[idx] = 0; g_thumb_failed[idx] = 0;
g_tex_err[idx][0] = '\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 /* latitude band of the source image, in normalised Web-Mercator. NOT simply
* image-height/width: MapChart's vertical scale differs slightly from its * image-height/width: the source's vertical scale differs ~0.4% from its
* horizontal, so these were fit against the real marker coordinates (so the * horizontal, so these were fit against the image's baked-in graticule (the
* photos land on land, not offset north into the water). ~76.6°N .. -59.9°S. */ * equator sits at y=3213 of 6352, ~37px below image centre). ~83.7°N .. -83.3°S. */
#define MAP_IMG_MY0 0.03993 /* top edge (~83.5°N) */ #define MAP_IMG_MY0 0.03814 /* top edge (~83.7°N) */
#define MAP_IMG_MY1 0.95395 /* bottom edge (~-83.5°S) */ #define MAP_IMG_MY1 0.95125 /* bottom edge (~-83.3°S) */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* raster tile pyramid (app0:maptiles.pak) — the LOD base map. */ /* 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 */ /* draw every cluster bubble; `sel` gets a white selection ring */
static void map_draw_clusters(int sel) 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); 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;
}
+35
View File
@@ -63,6 +63,29 @@ static int g_sect_count;
static int g_sect_cap; static int g_sect_cap;
static float g_content_h; /* total scrollable height incl. headers */ static float g_content_h; /* total scrollable height incl. headers */
/* map cluster gallery: the photos of one map bubble, shown on the SAME grid as
* the timeline/search — a filtered view like g_search_*. g_gallery_idx holds
* indices into the g_asset_* arrays for the bubble's members; members not
* already paged into the library are appended as g_asset entries flagged
* g_asset_hidden (so they appear only here, never in the main timeline). Their
* date/aspect are resolved lazily by the metadata worker below, so the grid
* reflows into real month sections + true aspect ratios as they arrive. */
static int g_gallery_active;
static int *g_gallery_idx;
static int g_gallery_count;
static int g_gallery_cap;
/* background metadata resolver: a one-slot handoff (mirroring the thumb worker)
* that fetches one asset's date/aspect so the justified grid can place it. */
enum { META_IDLE, META_PENDING, META_DONE };
static volatile int g_meta_state = META_IDLE;
static char g_meta_id[40]; /* asset id to resolve (main -> worker) */
static int g_meta_target; /* g_asset index to fill (main-thread owned) */
static char g_meta_date[DATELEN]; /* results (worker -> main) */
static float g_meta_ratio;
static unsigned char g_meta_rot;
static int g_meta_is_video;
/* free-text smart (CLIP) search. when active the grid shows only the matching /* free-text smart (CLIP) search. when active the grid shows only the matching
* server assets — still date-grouped under month headers like the timeline — * server assets — still date-grouped under month headers like the timeline —
* instead of the full merged library; TRIANGLE opens the keyboard, CIRCLE (or * instead of the full merged library; TRIANGLE opens the keyboard, CIRCLE (or
@@ -159,6 +182,7 @@ static int grow_assets(int need)
!GROW(g_asset_rot, c, need) || !GROW(g_asset_rot, c, need) ||
!GROW(g_asset_is_video, c, need) || !GROW(g_asset_is_video, c, need) ||
!GROW(g_asset_local_backed, c, need) || !GROW(g_asset_local_backed, c, need) ||
!GROW(g_asset_hidden, c, need) ||
!GROW(g_thumb, c, need) || !GROW(g_thumb, c, need) ||
!GROW(g_thumb_failed, c, need) || !GROW(g_thumb_failed, c, need) ||
!(g_asset_cap = GROW(g_tex_err, c, need))) !(g_asset_cap = GROW(g_tex_err, c, need)))
@@ -214,6 +238,17 @@ static int grow_sect(int need)
return 1; return 1;
} }
static int grow_gallery(int need)
{
if (need <= g_gallery_cap)
return 1;
int cap = GROW(g_gallery_idx, g_gallery_cap, need);
if (!cap)
return 0;
g_gallery_cap = cap;
return 1;
}
static void log_line(const char *fmt, ...) static void log_line(const char *fmt, ...)
{ {
FILE *f = fopen(LOG_PATH, "a"); FILE *f = fopen(LOG_PATH, "a");
+13 -9
View File
@@ -1,9 +1,10 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Generate a Web-Mercator tile pyramid (LODs) from a MapChart world PNG and """Generate a Web-Mercator tile pyramid (LODs) from a world map image and
pack it into one file for the Vita map view. pack it into one file for the Vita map view.
The source PNG covers the same cropped-Mercator region as before: longitude The source image covers a cropped-Mercator region: longitude spans its full
-180..180 across its full width, latitude band [MAP_IMG_MY0, MAP_IMG_MY1]. width, latitude band [MAP_IMG_MY0, MAP_IMG_MY1]. The default source carries a
baked-in 10° graticule, against which those bounds were fit (see map.c).
Each LOD level is the source scaled to a width, padded to whole 256px tiles, Each LOD level is the source scaled to a width, padded to whole 256px tiles,
and cut up; tiles are JPEG (decoded into pooled textures on device, the safe and cut up; tiles are JPEG (decoded into pooled textures on device, the safe
path). Everything goes into assets/maptiles.pak: path). Everything goes into assets/maptiles.pak:
@@ -18,12 +19,15 @@ offsets are from the start of the JPEG blob.
import sys, struct, io import sys, struct, io
from PIL import Image from PIL import Image
SRC = sys.argv[1] if len(sys.argv) > 1 else '/home/sads/Desktop/MapChart_Map (3).png' SRC = sys.argv[1] if len(sys.argv) > 1 else '/home/sads/Desktop/world map cities.jpeg'
OUT = sys.argv[2] if len(sys.argv) > 2 else '/home/sads/Code/vitaImmich/assets/maptiles.pak' OUT = sys.argv[2] if len(sys.argv) > 2 else '/home/sads/Code/vitaImmich/assets/maptiles.pak'
TILE = 256 TILE = 256
QUALITY = 90 QUALITY = 94
# level widths (px) for the full map; last is ~native. heights follow aspect. # level widths (px). The last entry (10496 ~= 1.5x the 7000px source) is a
WIDTHS = [512, 1024, 2048, 4096, 7000] # LANCZOS supersample: it adds no true detail beyond native, but lets a zoomed-in
# view sample crisp pre-upscaled tiles instead of the GPU bilinear-magnifying the
# native level, so city labels read sharper. heights follow aspect.
WIDTHS = [512, 1024, 2048, 4096, 7000, 10496]
src = Image.open(SRC).convert('RGB') src = Image.open(SRC).convert('RGB')
SW, SH = src.size SW, SH = src.size
@@ -31,8 +35,8 @@ print('source', SW, SH, 'aspect', round(SW/SH, 4))
levels = [] # (imgW,imgH,cols,rows,Image-padded) levels = [] # (imgW,imgH,cols,rows,Image-padded)
for w in WIDTHS: for w in WIDTHS:
if w > SW: if w > 2 * SW: # allow a modest supersample, but cap runaway upscales
w = SW w = 2 * SW
h = round(w * SH / SW) h = round(w * SH / SW)
img = src.resize((w, h), Image.LANCZOS) img = src.resize((w, h), Image.LANCZOS)
cols = (w + TILE - 1) // TILE cols = (w + TILE - 1) // TILE