diff --git a/CLAUDE.md b/CLAUDE.md index 916b63a..9115a22 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -129,7 +129,7 @@ it live, which is exactly why we capture every frame to disk. --- -## 4. Read the logs — verify from TEXT, not frames +## 4. Read the logs or screenshots **Capturing a frame is free** (PNGs on disk are not in context). **Reading a PNG back into context is the expensive part.** So capture every command, but **do not @@ -147,9 +147,7 @@ errors. Also available: - `/tmp/vita3k-runtime.log` (Vita3K runtime stdout/stderr from `deploy.sh`). - `/tmp/vita3k-install.log` (install output). -**Only Read a frame** when a screenshot is genuinely the only way to resolve an -ambiguity (rare). Reading every frame is what bloats context — that's what this -rule avoids. +read the screenshot if its usefull for the current task ### Typical verify loop diff --git a/CMakeLists.txt b/CMakeLists.txt index 6e2c53f..47bc797 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -60,4 +60,5 @@ vita_create_vpk(${PROJECT_NAME}.vpk ${VITA_TITLEID} ${PROJECT_NAME}.self FILE ${CMAKE_SOURCE_DIR}/assets/cloud_server.png cloud_server.png FILE ${CMAKE_SOURCE_DIR}/assets/cloud_device.png cloud_device.png FILE ${CMAKE_SOURCE_DIR}/assets/cloud_both.png cloud_both.png + FILE ${CMAKE_SOURCE_DIR}/assets/maptiles.pak maptiles.pak ) diff --git a/assets/maptiles.pak b/assets/maptiles.pak new file mode 100644 index 0000000..1ee3b9f Binary files /dev/null and b/assets/maptiles.pak differ diff --git a/src/app.c b/src/app.c index 141ac9b..96cc879 100644 --- a/src/app.c +++ b/src/app.c @@ -9,7 +9,7 @@ /* main */ /* ------------------------------------------------------------------ */ -enum { MODE_GRID, MODE_DETAIL, MODE_CLOUD, MODE_CLOUD_DETAILS }; +enum { MODE_GRID, MODE_DETAIL, MODE_CLOUD, MODE_CLOUD_DETAILS, MODE_MAP }; /* the whole boot workload (auto sign-in, first library page, local media scan) * runs on this one thread so the main thread can animate a single, continuous @@ -56,6 +56,17 @@ static int srvinfo_thread(SceSize args, void *argp) return 0; } +/* fetch the map's geotagged markers off the main thread, so the map page + * can animate a throbber while the request is in flight (fetch_map_markers + * sets g_map_state to 1/-1 when it finishes) */ +static int mapfetch_thread(SceSize args, void *argp) +{ + (void)args; (void)argp; + fetch_map_markers(); + g_map_fetching = 0; + return 0; +} + /* after a logout + sign-in (possibly a different account): drop the old * library/local lists and reload, with the sync thread paused and the * thumbnail worker drained so nothing reads/writes the arrays mid-reset. @@ -103,6 +114,9 @@ static void reset_for_account_change(void) g_search_count = 0; g_srv_state = 0; /* re-fetch the cloud page's server info */ g_srv_fetching = 0; + g_map_state = 0; /* re-fetch the new account's map markers */ + g_map_fetching = 0; + g_map_view_init = 0; __sync_synchronize(); /* reload from the new account (sync still paused), then resume */ @@ -115,7 +129,10 @@ static void reset_for_account_change(void) int main(void) { - vita2d_init(); + /* a larger vertex pool than the 1 MB default: the places map draws the + * whole world as filled vector triangles in one vita2d_draw_array call, + * which can be ~100k vertices when fully zoomed out */ + vita2d_init_advanced(8 * 1024 * 1024); vita2d_set_clear_color(RGBA8(16, 16, 16, 255)); g_font = vita2d_load_default_pgf(); g_ttf = vita2d_load_font_file("app0:font.ttf"); @@ -123,6 +140,7 @@ int main(void) g_ic_server = vita2d_load_PNG_file("app0:cloud_server.png"); g_ic_device = vita2d_load_PNG_file("app0:cloud_device.png"); g_ic_both = vita2d_load_PNG_file("app0:cloud_both.png"); + load_maptiles(); /* raster LOD tile pyramid for the places map */ /* linear (bilinear) sampling so these scale/rotate smoothly instead of * showing jagged stair-stepped edges (point sampling is the default) */ vita2d_texture *icons[] = { g_logo, g_ic_server, g_ic_device, g_ic_both }; @@ -277,6 +295,7 @@ int main(void) vita2d_texture *detail_tex = NULL; int detail_idx = -1; int detail_failed = 0; + int detail_from_map = 0; /* opened from the places map: O returns there */ int detail_loading = 0; /* full-res load in flight on the worker */ int detail_issued = 0; /* the worker request has been handed over */ float zoom = 1.0f, panx = 0.0f, pany = 0.0f; @@ -290,6 +309,7 @@ int main(void) float touch_start_x = 0, touch_start_y = 0, touch_start_scroll = 0; float touch_panx0 = 0, touch_pany0 = 0; /* pan at touch start (zoomed) */ float touch_vel = 0; /* px/frame at the moment of release */ + float map_pinch = 0; /* finger spread last frame (map zoom) */ /* detail-view photo slide: the current photo's horizontal offset. * a swipe maps it 1:1 to the finger; on release (or d-pad browse) it @@ -378,6 +398,7 @@ int main(void) int do_search_clear = g_search_active && (pressed & SCE_CTRL_CIRCLE) != 0; int do_open_cloud = 0; /* tap on the cloud button */ + int do_open_map = 0; /* tap on the map button */ /* SQUARE slides the top bar in/out (when not focused); * while focused it drops focus back to the grid */ @@ -413,9 +434,10 @@ int main(void) if (pressed & SCE_CTRL_CROSS) { if (bar_focus == 1) do_search_open = 1; + else if (bar_focus == 2) + do_open_map = 1; else if (bar_focus == 3) do_open_cloud = 1; - /* map (2): placeholder, no-op */ } } } else { @@ -506,6 +528,7 @@ int main(void) * view; a video shows its poster with the * play button there */ mode = MODE_DETAIL; + detail_from_map = 0; zoom = 1.0f; panx = pany = 0.0f; slide_x = slide_goal = 0; @@ -600,7 +623,8 @@ int main(void) * server/backup page */ do_open_cloud = 1; } else if (touch_x > BAR_PX + BAR_W) { - /* map button (placeholder) */ + /* map button: open the map */ + do_open_map = 1; } else if (g_search_active && touch_x > BAR_CLEAR_CX - 16) do_search_clear = 1; @@ -616,6 +640,7 @@ int main(void) if (i >= 0) { sel = i; mode = MODE_DETAIL; + detail_from_map = 0; zoom = 1.0f; panx = pany = 0.0f; slide_x = slide_goal = 0; @@ -643,6 +668,11 @@ int main(void) mode = MODE_CLOUD; continue; } + if (do_open_map) { + mode = MODE_MAP; + touch_active = touch_on_bar = 0; + continue; + } /* act on a search request from TRIANGLE/CIRCLE * or a tap on the bar. both block this frame @@ -1039,9 +1069,10 @@ int main(void) } } if (pressed & SCE_CTRL_CIRCLE) { - /* back to the grid; if a full-res load is in - * flight its result gets discarded there */ - mode = MODE_GRID; + /* back to wherever we came from (the map if the + * photo was opened from a marker, else the grid) */ + mode = detail_from_map ? MODE_MAP : MODE_GRID; + detail_from_map = 0; detail_idx = -1; detail_loading = 0; slide_x = slide_goal = 0; @@ -1734,6 +1765,185 @@ pf_skip: draw_hud("Up/Down scroll O back"); vita2d_end_drawing(); vita2d_swap_buffers(); + } else if (mode == MODE_MAP) { + /* geotag map, opened from the map button on the search + * bar. markers load on a worker thread the first time; + * the page animates a throbber until they arrive. */ + if (g_map_state == 0) { + if (!g_map_fetching) { + g_map_fetching = 1; + SceUID mt = sceKernelCreateThread("mapfetch", + mapfetch_thread, 0x10000100, + 64 * 1024, 0, 0, NULL); + if (mt >= 0) + sceKernelStartThread(mt, 0, NULL); + else { + fetch_map_markers(); /* fallback */ + g_map_fetching = 0; + } + } + vita2d_start_drawing(); + vita2d_clear_screen(); + draw_throbber(SCREEN_W / 2.0f, + SCREEN_H / 2.0f - 16.0f, 22.0f, frame); + draw_centered(SCREEN_H / 2 + 36, + RGBA8(200, 200, 200, 255), + "Loading map..."); + if (pressed & SCE_CTRL_CIRCLE) + mode = MODE_GRID; + vita2d_end_drawing(); + vita2d_swap_buffers(); + sceDisplayWaitVblankStart(); + continue; + } + if (g_map_state == -1) { + vita2d_start_drawing(); + vita2d_clear_screen(); + draw_centered(SCREEN_H / 2 - 10, + RGBA8(220, 120, 120, 255), + "Map unavailable (see log.txt)"); + draw_centered(SCREEN_H / 2 + 24, + RGBA8(160, 160, 165, 255), + "O back"); + if (pressed & SCE_CTRL_CIRCLE) + mode = MODE_GRID; + vita2d_end_drawing(); + vita2d_swap_buffers(); + sceDisplayWaitVblankStart(); + continue; + } + if (!g_map_view_init) { + map_fit_view(); + g_map_view_init = 1; + } + if (pressed & SCE_CTRL_CIRCLE) { + mode = MODE_GRID; + continue; + } + + /* d-pad pans (held = continuous), L/R zoom about centre. + * pan step is in screen px, converted to merc by the scale + * so it feels the same at every zoom level. */ + double pstep = 14.0 / g_map_scale; + if (pad.buttons & SCE_CTRL_LEFT) g_map_cx -= pstep; + if (pad.buttons & SCE_CTRL_RIGHT) g_map_cx += pstep; + if (pad.buttons & SCE_CTRL_UP) 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_LTRIGGER) g_map_scale /= 1.03; + map_clamp_view(); + + /* touch: one finger drags the map, two fingers pinch-zoom, + * a tap acts on the bubble under it (zoom in, or open a + * lone photo) */ + int tapped_cluster = -2; /* -2 none, -1 empty, >=0 bubble */ + { + SceTouchData td; + sceTouchPeek(SCE_TOUCH_PORT_FRONT, &td, 1); + if (td.reportNum >= 2) { + float ax = td.report[0].x * 0.5f; + float ay = td.report[0].y * 0.5f; + float bx = td.report[1].x * 0.5f; + float by = td.report[1].y * 0.5f; + float dist = sqrtf((ax - bx) * (ax - bx) + + (ay - by) * (ay - by)); + if (map_pinch > 1.0f && dist > 1.0f) { + g_map_scale *= dist / map_pinch; + map_clamp_view(); + } + map_pinch = dist; + touch_active = 1; + touch_dragged = 1; + } else if (td.reportNum == 1) { + float tx = td.report[0].x * 0.5f; + float ty = td.report[0].y * 0.5f; + map_pinch = 0; + if (!touch_active) { + touch_active = 1; + touch_dragged = 0; + touch_start_x = tx; + touch_start_y = ty; + touch_x = tx; + touch_y = ty; + } else { + if (fabsf(tx - touch_start_x) > 10 || + fabsf(ty - touch_start_y) > 10) + touch_dragged = 1; + if (touch_dragged) { + g_map_cx -= (tx - touch_x) / + g_map_scale; + g_map_cy -= (ty - touch_y) / + g_map_scale; + map_clamp_view(); + } + touch_x = tx; + touch_y = ty; + } + } else { + if (touch_active && !touch_dragged) { + tapped_cluster = + map_cluster_at(touch_x, touch_y); + if (tapped_cluster < 0) + tapped_cluster = -1; + } + touch_active = 0; + map_pinch = 0; + } + } + + map_cluster(); + int nc = map_nearest_cluster(); + + /* X acts on the bubble nearest the centre; a tap acts on + * the bubble under the finger. a lone photo opens in the + * detail view (if it's been paged into the library), + * a multi-photo bubble zooms in to split it apart. */ + int act = -1; + if (pressed & SCE_CTRL_CROSS) + act = nc; + else if (tapped_cluster >= 0) + act = tapped_cluster; + if (act >= 0 && act < g_cl_count) { + if (map_should_open(act)) { + int s; + if (map_open_marker(g_cl_rep[act], &s)) { + sel = s; + mode = MODE_DETAIL; + detail_from_map = 1; + zoom = 1.0f; + panx = pany = 0.0f; + slide_x = slide_goal = 0; + slide_anim = 0; + detail_idx = -1; + grid_last_sel = -1; + continue; + } + map_zoom_into(act); /* couldn't open: just zoom */ + } else { + map_zoom_into(act); + } + } + + vita2d_start_drawing(); + vita2d_clear_screen(); + map_draw_tiles(); + map_draw_clusters(nc); + /* centre crosshair: shows what X will act on */ + vita2d_draw_rectangle(SCREEN_W / 2.0f - 8, SCREEN_H / 2.0f, + 16, 1, RGBA8(255, 255, 255, 90)); + vita2d_draw_rectangle(SCREEN_W / 2.0f, SCREEN_H / 2.0f - 8, + 1, 16, RGBA8(255, 255, 255, 90)); + { + char mh[96]; + snprintf(mh, sizeof(mh), "Places - %d geotagged", + g_map_count); + draw_text(12, 28, RGBA8(235, 235, 240, 255), 1.0f, mh); + draw_text(12, SCREEN_H - 16, + RGBA8(150, 150, 158, 255), 0.85f, + "d-pad pan L/R zoom X open/zoom O back"); + } + vita2d_end_drawing(); + vita2d_swap_buffers(); } /* Pace the loop to the display (~60 Hz). vita2d's swap doesn't diff --git a/src/main.c b/src/main.c index fe570bd..ff3449c 100644 --- a/src/main.c +++ b/src/main.c @@ -154,6 +154,7 @@ static char (*g_tex_err)[160]; #include "thumbs.c" /* background thumbnail loader + texture pool */ #include "display.c" /* merged server+local timeline + grid layout */ #include "ui.c" /* grid/detail/search/login/cloud screens */ +#include "map.c" /* geotag map: markers, clustering, projection */ #include "video.c" /* video download + SceAvPlayer playback */ #include "sync.c" /* local scan/hash/check/upload + sync overview */ #include "app.c" /* main(): init, event loop, threads */ diff --git a/src/map.c b/src/map.c new file mode 100644 index 0000000..f5e4000 --- /dev/null +++ b/src/map.c @@ -0,0 +1,666 @@ +/* vitaImmich unity build — part of one translation unit. + * This file is #included by src/main.c (in order) and shares all + * file-scope state with the other parts; it is NOT compiled on its own. + * Only src/main.c is listed in CMakeLists.txt. Editors/clangd may flag + * undefined symbols here because of that — those are expected. + */ + +/* ------------------------------------------------------------------ */ +/* map / "places" view */ +/* */ +/* Browse geotagged photos on a world map. Markers come from Immich's */ +/* GET /api/map/markers (a flat {id,lat,lon} list). There is no raster */ +/* base map: markers are projected with Web Mercator onto a dark */ +/* canvas with a faint lat/lon graticule, and nearby ones are grouped */ +/* into count "bubbles" (re-clustered every frame in screen space, so */ +/* zooming in splits a bubble apart). Pan with the d-pad / touch drag, */ +/* zoom with L/R / pinch, X acts on the bubble nearest the centre */ +/* (zoom into a cluster, or open a lone photo), O returns to the grid. */ +/* ------------------------------------------------------------------ */ + +/* marker arrays, grown (doubling) by the fetch; read by the main thread + * only once g_map_state flips to 1 (so a half-parsed list never draws) */ +static double *g_map_lat; +static double *g_map_lon; +static char (*g_map_id)[40]; +static int g_map_count; +static int g_map_cap; +static volatile int g_map_state; /* 0 not fetched, 1 ok, -1 failed */ +static volatile int g_map_fetching; /* a worker thread is fetching now */ + +/* view: Web-Mercator centre in normalised [0,1] coords and the world's + * 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. */ +static double g_map_cx = 0.5, g_map_cy = 0.5; +static double g_map_scale = 512.0; +static int g_map_view_init; + +#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 */ + +/* 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. */ +static double merc_x(double lon) +{ + return (lon - MAP_LON0) / (MAP_LON1 - MAP_LON0); +} +static double merc_y(double lat) +{ + if (lat > 85.05113) lat = 85.05113; + if (lat < -85.05113) lat = -85.05113; + double s = sin(lat * MAP_PI / 180.0); + return 0.5 - log((1.0 + s) / (1.0 - s)) / (4.0 * MAP_PI); +} + +/* normalised merc coords -> screen pixels under the current view */ +static void map_project(double mx, double my, float *sx, float *sy) +{ + *sx = (float)((mx - g_map_cx) * g_map_scale) + SCREEN_W / 2.0f; + *sy = (float)((my - g_map_cy) * g_map_scale) + SCREEN_H / 2.0f; +} + +static int grow_map(int need) +{ + int c = g_map_cap; + if (need <= c) + return 1; + if (!GROW(g_map_lat, c, need) || + !GROW(g_map_lon, c, need) || + !(g_map_cap = GROW(g_map_id, c, need))) + return 0; + return 1; +} + +/* parse a /api/map/markers response: a flat JSON array of objects, each + * with a string "id" and numeric "lat"/"lon". returns the marker count, + * or -1 on a parse error. (other fields — city/state/country — ignored.) */ +static int parse_map_markers(const char *js, size_t len) +{ + jsmn_parser p; + jsmn_init(&p); + int nt = jsmn_parse(&p, js, len, NULL, 0); + if (nt <= 0) + return -1; + jsmntok_t *t = malloc(sizeof(*t) * nt); + if (!t) + return -1; + jsmn_init(&p); + nt = jsmn_parse(&p, js, len, t, nt); + if (nt <= 0 || t[0].type != JSMN_ARRAY) { + free(t); + return -1; + } + + g_map_count = 0; + int cur = -1, cur_obj = -1; + for (int i = 1; i < nt; i++) { + if (t[i].type == JSMN_OBJECT && t[i].parent == 0) { + if (!grow_map(g_map_count + 1)) + break; /* out of memory: keep what we have */ + cur = g_map_count++; + cur_obj = i; + g_map_id[cur][0] = '\0'; + g_map_lat[cur] = 0.0; + g_map_lon[cur] = 0.0; + continue; + } + if (cur < 0 || t[i].type != JSMN_STRING || t[i].size != 1 || + i + 1 >= nt || t[i].parent != cur_obj) + 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 (kl == 2 && !strncmp(k, "id", 2) && vl > 0 && + vl < (int)sizeof(g_map_id[0])) + snprintf(g_map_id[cur], sizeof(g_map_id[0]), "%.*s", + vl, js + v->start); + else if (kl == 3 && !strncmp(k, "lat", 3)) + g_map_lat[cur] = atof(js + v->start); + else if (kl == 3 && !strncmp(k, "lon", 3)) + g_map_lon[cur] = atof(js + v->start); + } + free(t); + + /* drop trailing entries that somehow lack an id */ + while (g_map_count > 0 && g_map_id[g_map_count - 1][0] == '\0') + g_map_count--; + return g_map_count; +} + +/* fetch all geotagged markers (GET /api/map/markers). sets g_map_state to + * 1 on success / -1 on failure; runs on a worker thread so the page can + * animate a throbber while the request is in flight. */ +static void fetch_map_markers(void) +{ + char url[600]; + snprintf(url, sizeof(url), "%s/api/map/markers", g_server); + membuf buf; + long code; + CURLcode res = http_request(url, NULL, &buf, &code, NULL, 0); + if (res != CURLE_OK || code < 200 || code >= 300) { + log_line("map markers: curl %d http %ld: %.160s", res, code, + buf.data ? buf.data : ""); + free(buf.data); + __sync_synchronize(); + g_map_state = -1; + return; + } + int n = parse_map_markers(buf.data, buf.size); + free(buf.data); + if (n < 0) { + log_line("map markers: parse failed"); + __sync_synchronize(); + g_map_state = -1; + return; + } + log_line("map markers: %d geotagged", n); + __sync_synchronize(); + g_map_state = 1; +} + +/* 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_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; + if (g_map_cy > 1.0) g_map_cy = 1.0; +} + +/* frame the markers' bounding box (with margin), or the whole world if there + * are none / they share a point */ +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; + map_clamp_view(); + return; + } + double minx = 1e9, maxx = -1e9, miny = 1e9, maxy = -1e9; + for (int i = 0; i < g_map_count; i++) { + double mx = merc_x(g_map_lon[i]), my = merc_y(g_map_lat[i]); + if (mx < minx) minx = mx; + if (mx > maxx) maxx = mx; + if (my < miny) miny = my; + if (my > maxy) maxy = my; + } + g_map_cx = (minx + maxx) / 2.0; + g_map_cy = (miny + maxy) / 2.0; + double w = maxx - minx, h = maxy - miny; + if (w < 1e-9) w = 1e-9; + 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; + map_clamp_view(); +} + +/* clusters, rebuilt every frame from the on-screen markers. markers are + * bucketed into a fixed screen grid; each non-empty cell becomes one bubble + * at its members' centroid. g_cl_rep is a member's marker index, used to open + * the photo when a bubble holds just one. */ +#define MAP_CELL 58.0f +#define MAP_MAXCLUST 600 +static float g_cl_x[MAP_MAXCLUST], g_cl_y[MAP_MAXCLUST]; +static int g_cl_gx[MAP_MAXCLUST], g_cl_gy[MAP_MAXCLUST]; /* merc-grid cell */ +static int g_cl_n[MAP_MAXCLUST]; +static int g_cl_rep[MAP_MAXCLUST]; +static double g_cl_sx[MAP_MAXCLUST], g_cl_sy[MAP_MAXCLUST]; /* screen sums */ +static float g_cl_x0[MAP_MAXCLUST], g_cl_y0[MAP_MAXCLUST]; /* member screen bbox */ +static float g_cl_x1[MAP_MAXCLUST], g_cl_y1[MAP_MAXCLUST]; +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). */ +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 */ + 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); + /* only consider markers near the viewport (generous margin so a + * bubble straddling the edge keeps a steady count while panning) */ + if (sx < -90 || sx > SCREEN_W + 90 || + sy < -90 || sy > SCREEN_H + 90) + continue; + int gx = (int)(mx / cellsz), gy = (int)(my / cellsz); + int c = -1; + for (int j = 0; j < g_cl_count; j++) + if (g_cl_gx[j] == gx && g_cl_gy[j] == gy) { + c = j; + break; + } + if (c < 0) { + if (g_cl_count >= MAP_MAXCLUST) + continue; /* grid saturated: drop the overflow */ + c = g_cl_count++; + g_cl_gx[c] = gx; + g_cl_gy[c] = gy; + g_cl_n[c] = 0; + g_cl_sx[c] = g_cl_sy[c] = 0.0; + g_cl_x0[c] = g_cl_x1[c] = sx; + g_cl_y0[c] = g_cl_y1[c] = sy; + g_cl_rep[c] = i; + } + g_cl_n[c]++; + g_cl_sx[c] += sx; + g_cl_sy[c] += sy; + if (sx < g_cl_x0[c]) g_cl_x0[c] = sx; + if (sx > g_cl_x1[c]) g_cl_x1[c] = sx; + if (sy < g_cl_y0[c]) g_cl_y0[c] = sy; + if (sy > g_cl_y1[c]) g_cl_y1[c] = sy; + } + for (int j = 0; j < g_cl_count; j++) { + g_cl_x[j] = (float)(g_cl_sx[j] / g_cl_n[j]); + g_cl_y[j] = (float)(g_cl_sy[j] / g_cl_n[j]); + } +} + +/* bubble radius (px) for a member count: lone markers are small dots, the + * rest grow with log(count) so a 200-photo cluster isn't 200x a 2-photo one */ +static float map_bubble_r(int count) +{ + if (count <= 1) + return 7.0f; + float r = 14.0f + 9.0f * log10f((float)count); + return r > 40.0f ? 40.0f : r; +} + +/* cluster nearest the screen centre (what X acts on), or -1 if none */ +static int map_nearest_cluster(void) +{ + int best = -1; + float bestd = 1e18f; + for (int j = 0; j < g_cl_count; j++) { + float dx = g_cl_x[j] - SCREEN_W / 2.0f; + float dy = g_cl_y[j] - SCREEN_H / 2.0f; + float d = dx * dx + dy * dy; + if (d < bestd) { + bestd = d; + best = j; + } + } + return best; +} + +/* cluster whose bubble contains screen point (x,y), or -1: touch hit-test */ +static int map_cluster_at(float x, float y) +{ + for (int j = 0; j < g_cl_count; j++) { + float r = map_bubble_r(g_cl_n[j]) + 6.0f; + float dx = g_cl_x[j] - x, dy = g_cl_y[j] - y; + if (dx * dx + dy * dy <= r * r) + return j; + } + 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 + * library and rebuild the timeline so it has a slot. main-thread only (same + * pattern as fetch_page). returns 1 and fills *out_sel on success. */ +static int map_open_marker(int marker_idx, int *out_sel) +{ + if (marker_idx < 0 || marker_idx >= g_map_count) + return 0; + const char *id = g_map_id[marker_idx]; + int idx = -1; + for (int i = 0; i < g_asset_count; i++) + if (!strcmp(g_asset_ids[i], id)) { + idx = i; + break; + } + + if (idx < 0) { + char url[600]; + snprintf(url, sizeof(url), "%s/api/assets/%s", g_server, id); + membuf buf; + long code; + CURLcode r = http_request(url, NULL, &buf, &code, NULL, 0); + if (r != CURLE_OK || code < 200 || code >= 300) { + log_line("map open %s: curl %d http %ld", id, r, code); + free(buf.data); + return 0; + } + char dval[DATELEN] = "", tval[12] = ""; + json_get(buf.data, buf.size, "fileCreatedAt", dval, sizeof(dval)); + json_get(buf.data, buf.size, "type", tval, sizeof(tval)); + free(buf.data); + if (!grow_assets(g_asset_count + 1)) + return 0; + idx = g_asset_count++; + snprintf(g_asset_ids[idx], sizeof(g_asset_ids[0]), "%s", id); + snprintf(g_asset_dates[idx], sizeof(g_asset_dates[0]), "%s", dval); + g_asset_ratio[idx] = 0.0f; /* unknown: detail view fits anyway */ + g_asset_rot[idx] = 0; + g_asset_is_video[idx] = !strncmp(tval, "VIDEO", 5); + g_asset_local_backed[idx] = 0; + g_thumb[idx] = NULL; + g_thumb_failed[idx] = 0; + g_tex_err[idx][0] = '\0'; + /* opening from the map shouldn't be hijacked by an active search */ + g_search_active = 0; + rebuild_display(); + log_line("map open: fetched asset %s (%s)", id, + g_asset_is_video[idx] ? "video" : "photo"); + } + int s = find_disp(SRC_SERVER, idx); + if (s >= 0) { + *out_sel = s; + return 1; + } + return 0; +} + +/* 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) */ + +/* ------------------------------------------------------------------ */ +/* raster tile pyramid (app0:maptiles.pak) — the LOD base map. */ +/* Generated from the MapChart PNG by tools/png_to_tiles.py: several */ +/* zoom levels, each cut into 256px JPEG tiles. We pick the level whose */ +/* native width matches the on-screen map width (crisp), and draw only */ +/* the visible tiles, decoding them into pooled textures (the safe path */ +/* — freeing textures mid-run crashes Vita3K's texture cache). Tiles */ +/* span mx[0,1] x my[MAP_IMG_MY0,MAP_IMG_MY1], same georeferencing as */ +/* the markers, so everything lines up. */ +/* ------------------------------------------------------------------ */ +#define TILE_PX 256 +#define MAP_MAXLEV 8 +static void map_draw_grid(void); /* fallback, defined below */ +static FILE *g_pak; +static long g_pak_blob; /* file offset where JPEG data begins */ +static int g_pak_nlev; +static int g_lev_w[MAP_MAXLEV], g_lev_h[MAP_MAXLEV]; /* content px */ +static int g_lev_cols[MAP_MAXLEV], g_lev_rows[MAP_MAXLEV]; +static int g_lev_base[MAP_MAXLEV]; /* index of this level's first tile */ +static unsigned int *g_ti_off, *g_ti_size; +static int g_ti_n; + +#define TCACHE 80 +static struct tile_slot { + int lev, col, row; + vita2d_texture *tex; + unsigned use; +} g_tc[TCACHE]; +static unsigned g_tc_clock; +static int g_tile_budget; /* new tile decodes allowed this frame */ + +static void load_maptiles(void) +{ + FILE *f = fopen("app0:maptiles.pak", "rb"); + if (!f) + return; + char magic[4]; + unsigned int ver, tile, nlev; + if (fread(magic, 1, 4, f) != 4 || memcmp(magic, "MTIL", 4) || + fread(&ver, 4, 1, f) != 1 || fread(&tile, 4, 1, f) != 1 || + fread(&nlev, 4, 1, f) != 1 || + tile != TILE_PX || nlev == 0 || nlev > MAP_MAXLEV) { + fclose(f); + return; + } + g_pak_nlev = (int)nlev; + for (int L = 0; L < g_pak_nlev; L++) { + unsigned int w, h, c, r; + if (fread(&w, 4, 1, f) != 1 || fread(&h, 4, 1, f) != 1 || + fread(&c, 4, 1, f) != 1 || fread(&r, 4, 1, f) != 1) { + fclose(f); + return; + } + g_lev_w[L] = w; g_lev_h[L] = h; + g_lev_cols[L] = c; g_lev_rows[L] = r; + } + unsigned int nt; + if (fread(&nt, 4, 1, f) != 1 || nt == 0 || nt > 200000) { + fclose(f); + return; + } + g_ti_off = malloc(nt * sizeof(*g_ti_off)); + g_ti_size = malloc(nt * sizeof(*g_ti_size)); + if (!g_ti_off || !g_ti_size) { + free(g_ti_off); free(g_ti_size); + g_ti_off = g_ti_size = NULL; + fclose(f); + return; + } + int curlev = -1; + unsigned int i; + for (i = 0; i < nt; i++) { + unsigned short lev, col, row; + unsigned int off, sz; + if (fread(&lev, 2, 1, f) != 1 || fread(&col, 2, 1, f) != 1 || + fread(&row, 2, 1, f) != 1 || fread(&off, 4, 1, f) != 1 || + fread(&sz, 4, 1, f) != 1) + break; + g_ti_off[i] = off; + g_ti_size[i] = sz; + if ((int)lev != curlev && lev < g_pak_nlev) { + curlev = lev; + g_lev_base[curlev] = (int)i; + } + } + g_pak_blob = ftell(f); /* index fully read: blob starts here */ + g_ti_n = (int)i; + g_pak = f; /* kept open for on-demand tile reads */ + log_line("maptiles: %d levels, %d tiles", g_pak_nlev, g_ti_n); +} + +/* read + decode one tile JPEG into a pooled texture (NULL on any failure) */ +static vita2d_texture *tile_decode(int L, int col, int row) +{ + if (!g_pak || L < 0 || L >= g_pak_nlev || + col < 0 || col >= g_lev_cols[L] || row < 0 || row >= g_lev_rows[L]) + return NULL; + int ti = g_lev_base[L] + row * g_lev_cols[L] + col; + if (ti < 0 || ti >= g_ti_n) + return NULL; + unsigned int off = g_ti_off[ti], sz = g_ti_size[ti]; + if (sz == 0 || sz > 4u * 1024 * 1024) + return NULL; + char *buf = malloc(sz); + if (!buf) + return NULL; + if (fseek(g_pak, g_pak_blob + off, SEEK_SET) != 0 || + fread(buf, 1, sz, g_pak) != sz) { + free(buf); + return NULL; + } + int w, h, comps; + char err[64]; + unsigned char *pix = decode_jpeg_buf(buf, sz, TILE_PX, &w, &h, &comps, + err, sizeof(err)); + free(buf); + if (!pix) { + log_line("tile %d/%d/%d: %s", L, col, row, err); + return NULL; + } + SceGxmTextureFormat fmt = (comps == 1) ? + SCE_GXM_TEXTURE_FORMAT_U8_R111 : + SCE_GXM_TEXTURE_FORMAT_U8U8U8_BGR; + vita2d_texture *t = tex_acquire(w, h, fmt); + if (t) { + unsigned char *dst = vita2d_texture_get_datap(t); + unsigned int stride = vita2d_texture_get_stride(t); + unsigned int rb = w * comps; + for (int y = 0; y < h; y++) + memcpy(dst + y * stride, pix + y * rb, rb); + vita2d_texture_set_filters(t, SCE_GXM_TEXTURE_FILTER_LINEAR, + SCE_GXM_TEXTURE_FILTER_LINEAR); + } + free(pix); + return t; +} + +/* fetch a tile from the cache, optionally loading (budget-limited) on a miss */ +static vita2d_texture *tile_get(int L, int col, int row, int may_load) +{ + for (int i = 0; i < TCACHE; i++) + if (g_tc[i].tex && g_tc[i].lev == L && + g_tc[i].col == col && g_tc[i].row == row) { + g_tc[i].use = ++g_tc_clock; + return g_tc[i].tex; + } + if (!may_load || g_tile_budget <= 0) + return NULL; + vita2d_texture *t = tile_decode(L, col, row); + if (!t) + return NULL; + g_tile_budget--; + int slot = -1; + unsigned oldest = 0xffffffffu; + for (int i = 0; i < TCACHE; i++) { + if (!g_tc[i].tex) { slot = i; break; } + if (g_tc[i].use < oldest) { oldest = g_tc[i].use; slot = i; } + } + if (g_tc[slot].tex) + tex_release(g_tc[slot].tex); /* recycle, never free */ + g_tc[slot].tex = t; + g_tc[slot].lev = L; + g_tc[slot].col = col; + g_tc[slot].row = row; + g_tc[slot].use = ++g_tc_clock; + return t; +} + +/* the LOD whose native width best matches the on-screen map width */ +static int tile_pick_level(void) +{ + int L = 0; + while (L < g_pak_nlev - 1 && g_lev_w[L] < (int)g_map_scale) + L++; + return L; +} + +/* draw every visible tile of level L (missing ones leave the coarser level + * below showing through, so there are no blank gaps while tiles stream in) */ +static void map_draw_level(int L) +{ + if (L < 0 || L >= g_pak_nlev) + return; + double span = MAP_IMG_MY1 - MAP_IMG_MY0; + double lw = g_lev_w[L], lh = g_lev_h[L]; + for (int r = 0; r < g_lev_rows[L]; r++) { + for (int c = 0; c < g_lev_cols[L]; c++) { + double mx0 = (c * TILE_PX) / lw; + double mx1 = ((c + 1) * TILE_PX) / lw; + double my0 = MAP_IMG_MY0 + ((r * TILE_PX) / lh) * span; + double my1 = MAP_IMG_MY0 + (((r + 1) * TILE_PX) / lh) * span; + float sx0, sy0, sx1, sy1; + map_project(mx0, my0, &sx0, &sy0); + map_project(mx1, my1, &sx1, &sy1); + if (sx1 < -1 || sx0 > SCREEN_W + 1 || + sy1 < -1 || sy0 > SCREEN_H + 1) + continue; + vita2d_texture *t = tile_get(L, c, r, 1); + if (!t) + continue; + vita2d_draw_texture_scale(t, sx0, sy0, + (sx1 - sx0) / TILE_PX, (sy1 - sy0) / TILE_PX); + } + } +} + +/* the tiled base map: a coarse overview first (no gaps), then the chosen LOD */ +static void map_draw_tiles(void) +{ + g_tile_budget = 6; /* cap new decodes per frame (avoids hitching) */ + if (g_pak_nlev <= 0) { + map_draw_grid(); /* fallback if the pak is missing */ + return; + } + map_draw_level(0); + int L = tile_pick_level(); + if (L > 0) + map_draw_level(L); +} + +/* faint lat/lon graticule for orientation (the only "base map" there is) */ +static void map_draw_grid(void) +{ + uint32_t c = RGBA8(40, 44, 54, 255); + for (int lon = -180; lon <= 180; lon += 30) { + float sx, sy; + map_project(merc_x(lon), 0.0, &sx, &sy); + if (sx >= 0 && sx <= SCREEN_W) + vita2d_draw_rectangle(sx, 0, 1, SCREEN_H, c); + } + for (int lat = -75; lat <= 75; lat += 15) { + float sx, sy; + map_project(0.0, merc_y(lat), &sx, &sy); + if (sy >= 0 && sy <= SCREEN_H) + vita2d_draw_rectangle(0, sy, SCREEN_W, 1, c); + } +} + +/* draw every cluster bubble; `sel` gets a white selection ring */ +static void map_draw_clusters(int sel) +{ + uint32_t fill = RGBA8(94, 110, 215, 235); + uint32_t dot = RGBA8(120, 140, 235, 235); + uint32_t white = RGBA8(245, 245, 250, 255); + for (int j = 0; j < g_cl_count; j++) { + float x = g_cl_x[j], y = g_cl_y[j]; + int cnt = g_cl_n[j]; + float r = map_bubble_r(cnt); + if (j == sel) + draw_disc(x, y, (r + 3.0f) * 2.0f, white); + if (cnt <= 1) { + draw_disc(x, y, r * 2.0f, dot); + continue; + } + draw_disc(x, y, r * 2.0f, fill); + char b[12]; + snprintf(b, sizeof(b), "%d", cnt); + int w = text_width(0.8f, b); + draw_text(x - w / 2.0f, y + 6.0f, white, 0.8f, b); + } +} diff --git a/tools/png_to_tiles.py b/tools/png_to_tiles.py new file mode 100644 index 0000000..253f0be --- /dev/null +++ b/tools/png_to_tiles.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Generate a Web-Mercator tile pyramid (LODs) from a MapChart world PNG and +pack it into one file for the Vita map view. + +The source PNG covers the same cropped-Mercator region as before: longitude +-180..180 across its full width, latitude band [MAP_IMG_MY0, MAP_IMG_MY1]. +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 +path). Everything goes into assets/maptiles.pak: + + magic 'MTIL' (4) u32 version u32 tile_size u32 nlevels + per level: u32 imgW imgH cols rows + u32 ntiles + per tile: u16 level u16 col u16 row u32 offset u32 size + +offsets are from the start of the JPEG blob. +""" +import sys, struct, io +from PIL import Image + +SRC = sys.argv[1] if len(sys.argv) > 1 else '/home/sads/Desktop/MapChart_Map (3).png' +OUT = sys.argv[2] if len(sys.argv) > 2 else '/home/sads/Code/vitaImmich/assets/maptiles.pak' +TILE = 256 +QUALITY = 90 +# level widths (px) for the full map; last is ~native. heights follow aspect. +WIDTHS = [512, 1024, 2048, 4096, 7000] + +src = Image.open(SRC).convert('RGB') +SW, SH = src.size +print('source', SW, SH, 'aspect', round(SW/SH, 4)) + +levels = [] # (imgW,imgH,cols,rows,Image-padded) +for w in WIDTHS: + if w > SW: + w = SW + h = round(w * SH / SW) + img = src.resize((w, h), Image.LANCZOS) + cols = (w + TILE - 1) // TILE + rows = (h + TILE - 1) // TILE + # pad to whole tiles with the ocean colour so edge tiles aren't black + bg = src.getpixel((4, SH // 2)) # a sea pixel (top-left corner area is sea) + padded = Image.new('RGB', (cols * TILE, rows * TILE), bg) + padded.paste(img, (0, 0)) + levels.append((w, h, cols, rows, padded)) + print('level', len(levels)-1, 'img', w, h, 'tiles', cols, 'x', rows, '=', cols*rows) + +# de-dup identical level widths (when native < a planned width) +seen = set(); uniq = [] +for L in levels: + if L[0] in seen: + continue + seen.add(L[0]); uniq.append(L) +levels = uniq + +blob = io.BytesIO() +index = [] # (level,col,row,offset,size) +for li, (w, h, cols, rows, padded) in enumerate(levels): + for r in range(rows): + for c in range(cols): + tile = padded.crop((c*TILE, r*TILE, c*TILE+TILE, r*TILE+TILE)) + b = io.BytesIO(); tile.save(b, 'JPEG', quality=QUALITY) + data = b.getvalue() + off = blob.tell(); blob.write(data) + index.append((li, c, r, off, len(data))) + +with open(OUT, 'wb') as f: + f.write(b'MTIL') + f.write(struct.pack('