mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 23:46:35 +00:00
feat(photos): a photo keeps its quality signals, measured once off the event loop
This commit is contained in:
@@ -0,0 +1,370 @@
|
||||
import {
|
||||
bookPhotoSuggestionsSchema,
|
||||
hashDistance,
|
||||
PHOTO_PICK,
|
||||
photoMoment,
|
||||
photosInScope,
|
||||
pickBookPhotos,
|
||||
type PhotoPickCandidate,
|
||||
type PhotoPickEntry,
|
||||
type PhotoPickSignals,
|
||||
} from './photo-pick';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* Picking photos for a book without looking at them: days, places, bursts,
|
||||
* look-alikes by hash, the doubtful ones set aside, a cover's shape, the limit
|
||||
* shared out over the groups. Lisbon's Alfama and Belem are 6 km apart; two
|
||||
* points 0.001 degrees of latitude apart are 111 m apart.
|
||||
*/
|
||||
|
||||
const ALFAMA = { lat: 38.7118, lng: -9.13 };
|
||||
const BELEM = { lat: 38.6916, lng: -9.216 };
|
||||
|
||||
const signals = (over: Partial<PhotoPickSignals> = {}): PhotoPickSignals => ({
|
||||
phash: null,
|
||||
sharpness: null,
|
||||
clipped: null,
|
||||
origWidth: null,
|
||||
origHeight: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
const photo = (photoId: number, over: Partial<PhotoPickCandidate> = {}): PhotoPickCandidate => ({
|
||||
photoId,
|
||||
takenAt: null,
|
||||
lat: null,
|
||||
lng: null,
|
||||
width: null,
|
||||
height: null,
|
||||
caption: null,
|
||||
entryIds: [],
|
||||
inBook: false,
|
||||
signals: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
const entry = (id: number, over: Partial<PhotoPickEntry> = {}): PhotoPickEntry => ({
|
||||
id,
|
||||
date: '2026-07-03',
|
||||
title: null,
|
||||
location: null,
|
||||
lat: null,
|
||||
lng: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
/** A photo in the Alfama on 3 July at hh:mm:ss local time (no zone). */
|
||||
const at = (photoId: number, time: string, over: Partial<PhotoPickCandidate> = {}) =>
|
||||
photo(photoId, { takenAt: `2026-07-03T${time}`, ...ALFAMA, ...over });
|
||||
|
||||
const ids = (result: ReturnType<typeof pickBookPhotos>) => result.groups.map((g) => g.picks.map((p) => p.photoId));
|
||||
|
||||
describe('photoMoment', () => {
|
||||
it('PHOTOPICK-001: a time without a zone is the wall clock, a UTC time moves by the longitude, an offset is taken as written', () => {
|
||||
const wall = Date.UTC(2026, 6, 3, 23, 30) / 1000;
|
||||
expect(photoMoment('2026-07-03T23:30:00', 139.7)).toEqual({ day: '2026-07-03', at: wall });
|
||||
expect(photoMoment('2026-07-03T20:00:00.000Z', 139.7)?.day).toBe('2026-07-04');
|
||||
expect(photoMoment('2026-07-03T20:00:00Z', null)?.day).toBe('2026-07-03');
|
||||
expect(photoMoment('2026-07-03T01:00:00Z', -122.4)?.day).toBe('2026-07-02');
|
||||
expect(photoMoment('2026-07-03 23:30:00+02:00', 139.7)).toEqual({
|
||||
day: '2026-07-03',
|
||||
at: Date.UTC(2026, 6, 3, 21, 30) / 1000,
|
||||
});
|
||||
expect(photoMoment('2026-07-03', 10)).toEqual({ day: '2026-07-03', at: null });
|
||||
expect(photoMoment('yesterday', 10)).toBeNull();
|
||||
expect(photoMoment(null, 10)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('hashDistance', () => {
|
||||
it('PHOTOPICK-002: counts the differing bits of two 64-bit hex hashes and refuses anything else', () => {
|
||||
expect(hashDistance('0123456789abcdef', '0123456789abcdef')).toBe(0);
|
||||
expect(hashDistance('0000000000000000', '000000000000000f')).toBe(4);
|
||||
expect(hashDistance('ffffffffffffffff', '0000000000000000')).toBe(64);
|
||||
expect(hashDistance('0000', '0000000000000000')).toBeNull();
|
||||
expect(hashDistance('zzzzzzzzzzzzzzzz', '0000000000000000')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickBookPhotos', () => {
|
||||
it('PHOTOPICK-003: groups by the linked entry day first, then the capture day, undated last, and answers the shared shape', () => {
|
||||
const entries = [entry(7, { date: '2026-07-01', location: 'Porto' })];
|
||||
const late = '2026-07-05T10:00:00';
|
||||
const photos = [photo(1), photo(2, { takenAt: late }), photo(3, { takenAt: late, entryIds: [7] })];
|
||||
const result = pickBookPhotos(photos, entries, { signalsPending: 2 });
|
||||
expect(bookPhotoSuggestionsSchema.parse(result)).toEqual(result);
|
||||
expect(result.groups.map((g) => [g.day, g.entryId, g.place])).toEqual([
|
||||
['2026-07-01', 7, 'Porto'],
|
||||
['2026-07-05', null, null],
|
||||
['undated', null, null],
|
||||
]);
|
||||
expect(ids(result)).toEqual([[3], [2], [1]]);
|
||||
expect(result.signals_pending).toBe(2);
|
||||
expect(result.groups_more).toBe(0);
|
||||
});
|
||||
|
||||
it('PHOTOPICK-004: splits a day into places 250 m apart, named after the nearest entry within 2 km, with its entry only on that day', () => {
|
||||
const entries = [
|
||||
entry(1, { location: 'Alfama', ...ALFAMA }),
|
||||
entry(2, { date: '2026-07-04', title: 'Belem tower', ...BELEM }),
|
||||
];
|
||||
const result = pickBookPhotos(
|
||||
[
|
||||
at(10, '09:00:00'),
|
||||
at(11, '09:10:00', { lat: ALFAMA.lat + 0.001 }),
|
||||
at(12, '15:00:00', BELEM),
|
||||
at(13, '16:00:00', { lat: ALFAMA.lat + 0.01 }),
|
||||
],
|
||||
entries,
|
||||
);
|
||||
expect(result.groups.map((g) => [g.place, g.entryId, g.picks.map((p) => p.photoId)])).toEqual([
|
||||
['Alfama', 1, [10, 11]],
|
||||
['Belem tower', null, [12]],
|
||||
['Alfama', 1, [13]],
|
||||
]);
|
||||
});
|
||||
|
||||
it('PHOTOPICK-005: a burst keeps its sharpest photo and names the others near duplicates of it', () => {
|
||||
const result = pickBookPhotos(
|
||||
[
|
||||
at(1, '10:00:00', { signals: signals({ sharpness: 200 }) }),
|
||||
at(2, '10:00:04', { signals: signals({ sharpness: 400 }) }),
|
||||
at(3, '10:00:09', { signals: signals({ sharpness: 300 }) }),
|
||||
at(4, '10:05:00', { signals: signals({ sharpness: 250 }) }),
|
||||
],
|
||||
[],
|
||||
);
|
||||
const [group] = result.groups;
|
||||
expect(group?.picks.map((p) => p.photoId)).toEqual([2, 4]);
|
||||
expect(group?.picks[0]?.why[0]).toBe('sharpest of 3 similar');
|
||||
expect(group?.dropped).toEqual([
|
||||
{ photoId: 3, reason: 'near_duplicate_of', of: 2 },
|
||||
{ photoId: 1, reason: 'near_duplicate_of', of: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('PHOTOPICK-006: photos far apart in time look alike when their hashes differ in at most 10 bits', () => {
|
||||
const result = pickBookPhotos(
|
||||
[
|
||||
at(1, '10:00:00', { signals: signals({ phash: '0000000000000000', sharpness: 100 }) }),
|
||||
at(2, '12:00:00', { signals: signals({ phash: '00000000000003ff', sharpness: 150 }) }),
|
||||
at(3, '14:00:00', { signals: signals({ phash: '0000000000000fff', sharpness: 120 }) }),
|
||||
],
|
||||
[],
|
||||
);
|
||||
// 1 and 2 differ in 10 bits, 2 and 3 in 2, so all three are one set even though 1 and 3 differ in 12.
|
||||
expect(ids(result)).toEqual([[2]]);
|
||||
expect(result.groups[0]?.dropped.map((d) => d.photoId).sort()).toEqual([1, 3]);
|
||||
const apart = pickBookPhotos(
|
||||
[
|
||||
at(1, '10:00:00', { signals: signals({ phash: '0000000000000000' }) }),
|
||||
at(2, '12:00:00', { signals: signals({ phash: '0000000000000fff' }) }),
|
||||
],
|
||||
[],
|
||||
);
|
||||
expect(ids(apart)).toEqual([[1, 2]]);
|
||||
});
|
||||
|
||||
it('PHOTOPICK-007: a possibly blurry photo is set aside and said so, unless it is all its group has', () => {
|
||||
const sharp = (id: number, time: string, sharpness: number) => at(id, time, { signals: signals({ sharpness }) });
|
||||
const result = pickBookPhotos(
|
||||
[
|
||||
sharp(1, '09:00:00', 300),
|
||||
sharp(2, '10:00:00', 280),
|
||||
sharp(3, '11:00:00', 20),
|
||||
photo(4, { takenAt: '2026-07-04T10:00:00', signals: signals({ sharpness: 15 }) }),
|
||||
],
|
||||
[],
|
||||
);
|
||||
expect(result.groups[0]?.picks.map((p) => p.photoId)).toEqual([1, 2]);
|
||||
expect(result.groups[0]?.dropped).toEqual([{ photoId: 3, reason: 'possibly_blurry' }]);
|
||||
expect(result.groups[1]?.picks).toEqual([{ photoId: 4, why: ['possibly blurry', 'not in the book yet'] }]);
|
||||
// Under 35 % of the typical photo but above the floor: not blurry.
|
||||
const soft = pickBookPhotos([sharp(1, '09:00:00', 900), sharp(2, '10:00:00', 1000), sharp(3, '11:00:00', 100)], []);
|
||||
expect(soft.groups[0]?.dropped).toEqual([]);
|
||||
// Fewer than three measured photos say nothing about what is typical.
|
||||
const two = pickBookPhotos([sharp(1, '09:00:00', 300), sharp(2, '10:00:00', 5)], []);
|
||||
expect(two.groups[0]?.dropped).toEqual([]);
|
||||
expect(PHOTO_PICK.minForMedian).toBe(3);
|
||||
});
|
||||
|
||||
it('PHOTOPICK-008: an original too small for print is set aside, a cover asks for more pixels, an unknown size counts as fine', () => {
|
||||
const sized = (id: number, time: string, w: number | null) =>
|
||||
at(id, time, { signals: signals({ origWidth: w, origHeight: w === null ? null : Math.round(w * 0.75) }) });
|
||||
const photos = [
|
||||
sized(1, '09:00:00', 4000),
|
||||
sized(2, '10:00:00', 1000),
|
||||
sized(3, '11:00:00', 2000),
|
||||
sized(4, '12:00:00', null),
|
||||
];
|
||||
const page = pickBookPhotos(photos, []);
|
||||
expect(page.groups[0]?.dropped).toEqual([{ photoId: 2, reason: 'low_resolution' }]);
|
||||
const cover = pickBookPhotos(photos, [], { purpose: 'cover' });
|
||||
expect(cover.groups[0]?.dropped.map((d) => d.photoId).sort()).toEqual([2, 3]);
|
||||
expect(cover.groups[0]?.picks.map((p) => p.photoId)).toEqual([1, 4]);
|
||||
});
|
||||
|
||||
it('PHOTOPICK-009: a cover puts a wide or square photo before a sharper upright one, a page goes by sharpness', () => {
|
||||
const photos = [
|
||||
at(1, '09:00:00', { width: 600, height: 800, signals: signals({ sharpness: 900 }) }),
|
||||
at(2, '10:00:00', { width: 800, height: 600, signals: signals({ sharpness: 300 }) }),
|
||||
at(3, '11:00:00', { width: 800, height: 800, signals: signals({ sharpness: 200 }) }),
|
||||
];
|
||||
const cover = pickBookPhotos(photos, [], { purpose: 'cover', limit: 2 });
|
||||
expect(cover.groups[0]?.picks.map((p) => [p.photoId, p.why[0]])).toEqual([
|
||||
[2, 'landscape'],
|
||||
[3, 'square'],
|
||||
]);
|
||||
expect(cover.groups[0]?.more).toBe(1);
|
||||
const page = pickBookPhotos(photos, [], { limit: 1 });
|
||||
expect(page.groups[0]?.picks.map((p) => [p.photoId, p.why[0]])).toEqual([[1, 'portrait']]);
|
||||
});
|
||||
|
||||
it('PHOTOPICK-010: without signals a caption wins, then a photo the book does not have yet, best first', () => {
|
||||
const result = pickBookPhotos(
|
||||
[
|
||||
at(1, '09:00:00', { inBook: true }),
|
||||
at(2, '10:00:00'),
|
||||
at(3, '11:00:00', { caption: 'Tram 28', inBook: true }),
|
||||
],
|
||||
[],
|
||||
{ limit: 2 },
|
||||
);
|
||||
expect(result.groups[0]?.picks).toEqual([
|
||||
{ photoId: 3, why: ['in the book already', 'has a caption'] },
|
||||
{ photoId: 2, why: ['not in the book yet'] },
|
||||
]);
|
||||
expect(result.groups[0]?.more).toBe(1);
|
||||
});
|
||||
|
||||
it('PHOTOPICK-011: the limit goes round the groups, so every group gets its best before any gets a second', () => {
|
||||
const day = (d: string, n: number) =>
|
||||
Array.from({ length: 3 }, (_, i) => photo(n + i, { takenAt: `2026-07-0${d}T1${i}:00:00` }));
|
||||
const photos = [...day('1', 10), ...day('2', 20), ...day('3', 30)];
|
||||
const four = pickBookPhotos(photos, [], { limit: 4 });
|
||||
expect(ids(four)).toEqual([[10, 11], [20], [30]]);
|
||||
expect(four.groups.map((g) => g.more)).toEqual([1, 2, 2]);
|
||||
const two = pickBookPhotos(photos, [], { limit: 2 });
|
||||
expect(ids(two)).toEqual([[10], [20]]);
|
||||
expect(two.groups_more).toBe(1);
|
||||
expect(pickBookPhotos(photos, [], { limit: 500 }).groups.flatMap((g) => g.picks)).toHaveLength(9);
|
||||
});
|
||||
|
||||
it('PHOTOPICK-012: an entry narrows to its linked photos, or to its day when none is linked; a date narrows to that day', () => {
|
||||
const entries = [entry(1, { date: '2026-07-01' }), entry(2, { date: '2026-07-02' })];
|
||||
const photos = [
|
||||
photo(1, { entryIds: [1] }),
|
||||
photo(2, { takenAt: '2026-07-01T10:00:00' }),
|
||||
photo(3, { takenAt: '2026-07-02T10:00:00' }),
|
||||
];
|
||||
expect(ids(pickBookPhotos(photos, entries, { entryId: 1 }))).toEqual([[1]]);
|
||||
expect(ids(pickBookPhotos(photos, entries, { entryId: 2 }))).toEqual([[3]]);
|
||||
expect(ids(pickBookPhotos(photos, entries, { entryId: 99 }))).toEqual([]);
|
||||
// Photo 1 has no capture time of its own, so it comes after the one that does.
|
||||
expect(ids(pickBookPhotos(photos, entries, { date: '2026-07-01' }))).toEqual([[2, 1]]);
|
||||
// The same narrowing, for the server to measure those photos first.
|
||||
expect([...photosInScope(photos, entries, { entryId: 2 })]).toEqual([3]);
|
||||
expect([...photosInScope(photos, entries, { date: '2026-07-01' })]).toEqual([1, 2]);
|
||||
expect([...photosInScope(photos, entries)]).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('PHOTOPICK-013: a photo without a place takes its entry place, and a placeless group takes the name its one entry has', () => {
|
||||
const entries = [entry(5, { location: 'Sintra', lat: 38.7976, lng: -9.3906 }), entry(6, { location: 'Cascais' })];
|
||||
const result = pickBookPhotos(
|
||||
[photo(1, { entryIds: [5] }), photo(2, { entryIds: [6] }), photo(3, { entryIds: [6] })],
|
||||
entries,
|
||||
);
|
||||
expect(result.groups.map((g) => [g.place, g.entryId, g.picks.map((p) => p.photoId)])).toEqual([
|
||||
['Sintra', 5, [1]],
|
||||
['Cascais', 6, [2, 3]],
|
||||
]);
|
||||
});
|
||||
|
||||
it('PHOTOPICK-014: photos about as sharp compare by what comes next: clipping, then a caption, then not in the book', () => {
|
||||
const shot = (
|
||||
id: number,
|
||||
time: string,
|
||||
sharpness: number,
|
||||
clipped: number,
|
||||
over: Partial<PhotoPickCandidate> = {},
|
||||
) => at(id, time, { inBook: true, signals: signals({ sharpness, clipped }), ...over });
|
||||
const result = pickBookPhotos(
|
||||
[
|
||||
shot(1, '09:00:00', 310, 0),
|
||||
shot(2, '10:00:00', 300, 0.012, { caption: 'Tram 28' }),
|
||||
shot(3, '11:00:00', 305, 0.005, { inBook: false }),
|
||||
shot(4, '12:00:00', 600, 0.05),
|
||||
shot(5, '13:00:00', 305, 0.1, { caption: 'Miradouro', inBook: false }),
|
||||
],
|
||||
[],
|
||||
);
|
||||
// 4 is clearly the sharpest; 1, 2, 3 and 5 are within 15 % of each other, and 5 burns out a tenth of its pixels.
|
||||
expect(ids(result)).toEqual([[4, 2, 3, 1, 5]]);
|
||||
expect(PHOTO_PICK.bands).toEqual({ sharpness: 1.15, clipped: 0.02, pixels: 1.5 });
|
||||
});
|
||||
|
||||
it('PHOTOPICK-015: shots seconds apart whose hashes clearly differ stay apart; a burst within 20 bits is one set', () => {
|
||||
const hashed = (id: number, time: string, phash: string) => at(id, time, { signals: signals({ phash }) });
|
||||
const walk = pickBookPhotos(
|
||||
[
|
||||
hashed(1, '10:00:00', '0000000000000000'),
|
||||
hashed(2, '10:00:08', 'ffffffff00000000'),
|
||||
hashed(3, '10:00:16', 'ffffffffffffffff'),
|
||||
],
|
||||
[],
|
||||
);
|
||||
expect(ids(walk)).toEqual([[1, 2, 3]]);
|
||||
expect(walk.groups[0]?.dropped).toEqual([]);
|
||||
const burst = pickBookPhotos(
|
||||
[hashed(1, '10:00:00', '0000000000000000'), hashed(2, '10:00:08', '00000000000fffff')],
|
||||
[],
|
||||
);
|
||||
expect(ids(burst)).toEqual([[1]]);
|
||||
expect(burst.groups[0]?.picks[0]?.why[0]).toBe('best of 2 similar');
|
||||
expect(burst.groups[0]?.dropped).toEqual([{ photoId: 2, reason: 'near_duplicate_of', of: 1 }]);
|
||||
// The same 20 bits an hour apart are no longer alike.
|
||||
const later = pickBookPhotos(
|
||||
[hashed(1, '10:00:00', '0000000000000000'), hashed(2, '11:00:00', '00000000000fffff')],
|
||||
[],
|
||||
);
|
||||
expect(ids(later)).toEqual([[1, 2]]);
|
||||
expect(PHOTO_PICK.burstHashDistance).toBe(20);
|
||||
});
|
||||
|
||||
it('PHOTOPICK-016: dropped names at most 30 per group and 120 in all, and dropped_more counts the rest', () => {
|
||||
const burst = (day: number, n: number) =>
|
||||
Array.from({ length: n }, (_, i) =>
|
||||
photo(day * 100 + i + 1, { takenAt: `2026-07-0${day}T10:00:${String(i).padStart(2, '0')}` }),
|
||||
);
|
||||
const one = pickBookPhotos(burst(1, 36), []);
|
||||
expect(ids(one)).toEqual([[101]]);
|
||||
expect(one.groups[0]?.dropped).toHaveLength(30);
|
||||
expect(one.groups[0]?.dropped_more).toBe(5);
|
||||
const five = pickBookPhotos([1, 2, 3, 4, 5].flatMap((day) => burst(day, 31)), []);
|
||||
expect(bookPhotoSuggestionsSchema.parse(five)).toEqual(five);
|
||||
expect(five.groups.map((g) => [g.dropped.length, g.dropped_more])).toEqual([
|
||||
[30, 0],
|
||||
[30, 0],
|
||||
[30, 0],
|
||||
[30, 0],
|
||||
[0, 30],
|
||||
]);
|
||||
});
|
||||
|
||||
it("PHOTOPICK-017: a photo's shape comes from its row before the original's size, which a provider may report turned", () => {
|
||||
const result = pickBookPhotos(
|
||||
[
|
||||
at(1, '09:00:00', { width: 600, height: 800, signals: signals({ origWidth: 4000, origHeight: 3000 }) }),
|
||||
at(2, '10:00:00', { signals: signals({ origWidth: 3000, origHeight: 2000 }) }),
|
||||
],
|
||||
[],
|
||||
{ purpose: 'cover' },
|
||||
);
|
||||
expect(result.groups[0]?.picks.map((p) => [p.photoId, p.why[0]])).toEqual([
|
||||
[2, 'landscape'],
|
||||
[1, 'portrait'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,579 @@
|
||||
import { haversineMetres } from '../geo/haversine';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Photos for a book page, picked without a model looking at them: what the
|
||||
* rows know (day, place, capture time, size, caption, whether the book has the
|
||||
* photo already) and two cheap pixel signals measured once per photo on the
|
||||
* server (a perceptual hash and how sharp it is, photo-signals.service.ts).
|
||||
*
|
||||
* Pure: the server's `suggest_book_photos` tool calls it with the journey's
|
||||
* gallery, and a Studio button can call it later with the same data.
|
||||
*
|
||||
* What it cannot do, and the tool says so: judge beauty, find faces or closed
|
||||
* eyes, tell who is in a photo or what it shows, or know two photos show the
|
||||
* same scene from another angle (the hash only catches near-identical framing).
|
||||
*/
|
||||
|
||||
export const PHOTO_PICK = {
|
||||
/** Photos within this many metres of a place's centre belong to it. */
|
||||
placeRadiusM: 250,
|
||||
/** A place is named after the nearest entry location within this many metres. */
|
||||
labelRadiusM: 2000,
|
||||
/** Two photos taken this close together are one burst... */
|
||||
burstSeconds: 10,
|
||||
/** ...unless both hashes are measured and differ in more than this many bits (a walk shot every few seconds). */
|
||||
burstHashDistance: 20,
|
||||
/** Two photos whose hashes differ in at most this many of 64 bits look the same. */
|
||||
hashDistance: 10,
|
||||
/** Possibly blurry: sharper than less than this share of the journey's typical photo... */
|
||||
blurRatio: 0.35,
|
||||
/** ...and under this absolute floor (the variance of the grey Laplacian at a 240 px long edge). */
|
||||
blurFloor: 60,
|
||||
/**
|
||||
* Ranking compares in bands, so a later criterion still counts between two
|
||||
* photos that are about as good: sharpness within this factor of each other,
|
||||
* clipping within this share of the pixels, size within this factor.
|
||||
*/
|
||||
bands: { sharpness: 1.15, clipped: 0.02, pixels: 1.5 },
|
||||
/** The typical photo needs this many measured photos before anything counts as blurry. */
|
||||
minForMedian: 3,
|
||||
/** The long edge in pixels a photo needs for print: a part of a page, or a whole cover. */
|
||||
minLongEdge: { page: 1200, cover: 2400 },
|
||||
defaultLimit: 24,
|
||||
maxLimit: 60,
|
||||
/** What one group lists as left out, at most... */
|
||||
droppedPerGroup: 30,
|
||||
/** ...and all groups together; the rest is counted in each group's `dropped_more`. */
|
||||
droppedTotal: 120,
|
||||
/** Reasons one pick carries, at most. */
|
||||
whyPerPick: 6,
|
||||
} as const;
|
||||
|
||||
/** What the server measured on a photo's pixels; a null is a signal that could not be measured. */
|
||||
export interface PhotoPickSignals {
|
||||
phash: string | null;
|
||||
sharpness: number | null;
|
||||
clipped: number | null;
|
||||
/** The original's size in pixels, never a thumbnail's. */
|
||||
origWidth: number | null;
|
||||
origHeight: number | null;
|
||||
}
|
||||
|
||||
/** A photo of the journey's gallery as the picker reads it. */
|
||||
export interface PhotoPickCandidate {
|
||||
photoId: number;
|
||||
/** As the row has it: ISO with or without a zone, or a bare date. */
|
||||
takenAt: string | null;
|
||||
lat: number | null;
|
||||
lng: number | null;
|
||||
/** Any size with the photo's shape (a thumbnail's will do); null when unknown. */
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
caption: string | null;
|
||||
/** The journey entries the photo is linked to. */
|
||||
entryIds: readonly number[];
|
||||
inBook: boolean;
|
||||
signals: PhotoPickSignals | null;
|
||||
}
|
||||
|
||||
/** A journey entry: its day and where it was. */
|
||||
export interface PhotoPickEntry {
|
||||
id: number;
|
||||
date: string;
|
||||
title: string | null;
|
||||
location: string | null;
|
||||
lat: number | null;
|
||||
lng: number | null;
|
||||
}
|
||||
|
||||
export interface PhotoPickOptions {
|
||||
/** Only the photos linked to this entry (or, when it has none, taken on its day). */
|
||||
entryId?: number;
|
||||
/** Only the photos of this day, `YYYY-MM-DD`. */
|
||||
date?: string;
|
||||
/** A cover wants a wide or square photo and more pixels. */
|
||||
purpose?: 'page' | 'cover';
|
||||
limit?: number;
|
||||
/** Photos whose signals were still being measured; passed through to the answer. */
|
||||
signalsPending?: number;
|
||||
}
|
||||
|
||||
const photoIdSchema = z.number().int().positive();
|
||||
|
||||
export const photoPickDroppedSchema = z.discriminatedUnion('reason', [
|
||||
z.object({ photoId: photoIdSchema, reason: z.literal('near_duplicate_of'), of: photoIdSchema }),
|
||||
z.object({ photoId: photoIdSchema, reason: z.literal('possibly_blurry') }),
|
||||
z.object({ photoId: photoIdSchema, reason: z.literal('low_resolution') }),
|
||||
]);
|
||||
export type PhotoPickDropped = z.infer<typeof photoPickDroppedSchema>;
|
||||
|
||||
/** What `suggest_book_photos` answers: groups by day and place, each with its picks and what it left out. */
|
||||
export const bookPhotoSuggestionsSchema = z.object({
|
||||
groups: z.array(
|
||||
z.object({
|
||||
/** `YYYY-MM-DD`, or `undated` for photos with neither an entry nor a capture time. */
|
||||
day: z.string(),
|
||||
place: z.string().nullable(),
|
||||
entryId: z.number().int().nullable(),
|
||||
picks: z.array(z.object({ photoId: photoIdSchema, why: z.array(z.string()).max(PHOTO_PICK.whyPerPick) })),
|
||||
dropped: z.array(photoPickDroppedSchema).max(PHOTO_PICK.droppedPerGroup),
|
||||
/** Photos left out of this group that `dropped` has no room to name. */
|
||||
dropped_more: z.number().int().nonnegative(),
|
||||
/** Good photos of this group the limit left out. */
|
||||
more: z.number().int().nonnegative(),
|
||||
}),
|
||||
),
|
||||
/** Groups left out whole because the limit was reached. */
|
||||
groups_more: z.number().int().nonnegative(),
|
||||
/** Photos judged on their rows alone, their pixels not measured yet. */
|
||||
signals_pending: z.number().int().nonnegative(),
|
||||
});
|
||||
export type BookPhotoSuggestions = z.infer<typeof bookPhotoSuggestionsSchema>;
|
||||
|
||||
// ── Time ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const STAMP = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?(?:\.\d+)?\s*(Z|[+-]\d{2}:?\d{2})?$/i;
|
||||
const BARE_DATE = /^(\d{4}-\d{2}-\d{2})$/;
|
||||
|
||||
interface Moment {
|
||||
/** The local day the photo was taken on. */
|
||||
day: string;
|
||||
/** Seconds, comparable between photos; null for a bare date. */
|
||||
at: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* When a photo was taken. A time without a zone is the camera's wall clock and
|
||||
* its date is the day. A time in UTC is moved by the photo's longitude (an
|
||||
* hour per 15 degrees, the sun's time) when it has one, since the day a
|
||||
* traveller lived it is the local one; an explicit offset is taken as written.
|
||||
*/
|
||||
export function photoMoment(takenAt: string | null, lng: number | null): Moment | null {
|
||||
if (!takenAt) return null;
|
||||
const bare = BARE_DATE.exec(takenAt.trim());
|
||||
if (bare?.[1]) return { day: bare[1], at: null };
|
||||
const m = STAMP.exec(takenAt.trim());
|
||||
if (!m) return null;
|
||||
const [, y, mo, d, h, mi, s, zone] = m;
|
||||
const wall = Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(s ?? 0));
|
||||
if (Number.isNaN(wall)) return null;
|
||||
const offsetMin = zone ? zoneMinutes(zone) : 0;
|
||||
const at = (wall - offsetMin * 60_000) / 1000;
|
||||
if (zone === undefined || offsetMin !== 0) return { day: takenAt.trim().slice(0, 10), at };
|
||||
const shift = lng !== null ? Math.round(lng / 15) * 3600 : 0;
|
||||
return { day: new Date((at + shift) * 1000).toISOString().slice(0, 10), at };
|
||||
}
|
||||
|
||||
function zoneMinutes(zone: string): number {
|
||||
if (zone.toUpperCase() === 'Z') return 0;
|
||||
const digits = zone.replace(':', '');
|
||||
const sign = digits.startsWith('-') ? -1 : 1;
|
||||
return sign * (Number(digits.slice(1, 3)) * 60 + Number(digits.slice(3, 5)));
|
||||
}
|
||||
|
||||
// ── Likeness ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** A 64-bit hash as its two 32-bit halves, read once per photo rather than once per pair. */
|
||||
type Hash = readonly [number, number];
|
||||
|
||||
const HASH = /^[0-9a-f]{16}$/i;
|
||||
|
||||
function parseHash(hex: string | null | undefined): Hash | null {
|
||||
if (!hex || !HASH.test(hex)) return null;
|
||||
return [parseInt(hex.slice(0, 8), 16), parseInt(hex.slice(8), 16)];
|
||||
}
|
||||
|
||||
/** The set bits of a 32-bit number. */
|
||||
function bitCount(x: number): number {
|
||||
let v = x - ((x >>> 1) & 0x55555555);
|
||||
v = (v & 0x33333333) + ((v >>> 2) & 0x33333333);
|
||||
return Math.imul((v + (v >>> 4)) & 0x0f0f0f0f, 0x01010101) >>> 24;
|
||||
}
|
||||
|
||||
function bitsApart(a: Hash, b: Hash): number {
|
||||
return bitCount(a[0] ^ b[0]) + bitCount(a[1] ^ b[1]);
|
||||
}
|
||||
|
||||
/** How many of the 64 bits two hashes (16 hex digits each) differ in; null when either is not one. */
|
||||
export function hashDistance(a: string, b: string): number | null {
|
||||
const ha = parseHash(a);
|
||||
const hb = parseHash(b);
|
||||
return ha && hb ? bitsApart(ha, hb) : null;
|
||||
}
|
||||
|
||||
// ── Picking ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** A candidate with what the picker worked out about it. */
|
||||
interface Placed {
|
||||
photo: PhotoPickCandidate;
|
||||
day: string;
|
||||
at: number | null;
|
||||
pos: { lat: number; lng: number } | null;
|
||||
}
|
||||
|
||||
interface Group {
|
||||
day: string;
|
||||
members: Placed[];
|
||||
centre: { lat: number; lng: number } | null;
|
||||
}
|
||||
|
||||
interface Ranked {
|
||||
photoId: number;
|
||||
at: number | null;
|
||||
why: string[];
|
||||
}
|
||||
|
||||
interface Judged {
|
||||
picks: Ranked[];
|
||||
dropped: PhotoPickDropped[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The photos `pickBookPhotos` judges under these options: those of the entry or
|
||||
* the day asked for, or all of them. The server measures these first.
|
||||
*/
|
||||
export function photosInScope(
|
||||
candidates: readonly PhotoPickCandidate[],
|
||||
entries: readonly PhotoPickEntry[],
|
||||
options: Pick<PhotoPickOptions, 'entryId' | 'date'> = {},
|
||||
): Set<number> {
|
||||
const byEntry = new Map(entries.map((e) => [e.id, e]));
|
||||
const placed = candidates.map((photo) => place(photo, byEntry));
|
||||
return new Set(filterFor(placed, options, byEntry).map((p) => p.photo.photoId));
|
||||
}
|
||||
|
||||
/** Photos for a page or the cover, grouped by day and place, best first within each group. */
|
||||
export function pickBookPhotos(
|
||||
candidates: readonly PhotoPickCandidate[],
|
||||
entries: readonly PhotoPickEntry[],
|
||||
options: PhotoPickOptions = {},
|
||||
): BookPhotoSuggestions {
|
||||
const purpose = options.purpose ?? 'page';
|
||||
const limit = Math.min(PHOTO_PICK.maxLimit, Math.max(1, options.limit ?? PHOTO_PICK.defaultLimit));
|
||||
const byEntry = new Map(entries.map((e) => [e.id, e]));
|
||||
const median = medianSharpness(candidates);
|
||||
const placed = candidates.map((photo) => place(photo, byEntry));
|
||||
const chosen = filterFor(placed, options, byEntry);
|
||||
const groups = groupByDayAndPlace(chosen);
|
||||
const judged = groups.map((g) => judge(g, purpose, median));
|
||||
const shown = takeRoundRobin(judged.map((j) => j.picks), limit);
|
||||
const out: BookPhotoSuggestions['groups'] = [];
|
||||
let groupsMore = 0;
|
||||
let droppedRoom: number = PHOTO_PICK.droppedTotal;
|
||||
groups.forEach((group, i) => {
|
||||
const picks = shown[i] ?? [];
|
||||
const all = judged[i];
|
||||
if (!all || picks.length === 0) {
|
||||
if (all && all.picks.length > 0) groupsMore++;
|
||||
return;
|
||||
}
|
||||
const label = labelOf(group, entries);
|
||||
const dropped = all.dropped.slice(0, Math.min(PHOTO_PICK.droppedPerGroup, droppedRoom));
|
||||
droppedRoom -= dropped.length;
|
||||
out.push({
|
||||
day: group.day,
|
||||
place: label.place,
|
||||
entryId: label.entryId,
|
||||
picks: picks.map((p) => ({ photoId: p.photoId, why: p.why.slice(0, PHOTO_PICK.whyPerPick) })),
|
||||
dropped,
|
||||
dropped_more: all.dropped.length - dropped.length,
|
||||
more: all.picks.length - picks.length,
|
||||
});
|
||||
});
|
||||
return { groups: out, groups_more: groupsMore, signals_pending: options.signalsPending ?? 0 };
|
||||
}
|
||||
|
||||
/** The sharpness of the journey's typical photo, over every measured one; null with too few to say. */
|
||||
function medianSharpness(candidates: readonly PhotoPickCandidate[]): number | null {
|
||||
const values = candidates
|
||||
.map((c) => c.signals?.sharpness)
|
||||
.filter((v): v is number => typeof v === 'number' && Number.isFinite(v))
|
||||
.sort((a, b) => a - b);
|
||||
if (values.length < PHOTO_PICK.minForMedian) return null;
|
||||
const mid = Math.floor(values.length / 2);
|
||||
const upper = values[mid] ?? 0;
|
||||
return values.length % 2 === 1 ? upper : ((values[mid - 1] ?? upper) + upper) / 2;
|
||||
}
|
||||
|
||||
/** The photo's day (its entry's first, else when it was taken) and where it was (its own place, else its entry's). */
|
||||
function place(photo: PhotoPickCandidate, byEntry: ReadonlyMap<number, PhotoPickEntry>): Placed {
|
||||
const entry = photo.entryIds.map((id) => byEntry.get(id)).find((e) => e !== undefined);
|
||||
const moment = photoMoment(photo.takenAt, photo.lng);
|
||||
const day = entry?.date ?? moment?.day ?? 'undated';
|
||||
const own = photo.lat !== null && photo.lng !== null ? { lat: photo.lat, lng: photo.lng } : null;
|
||||
const fromEntry = entry && entry.lat !== null && entry.lng !== null ? { lat: entry.lat, lng: entry.lng } : null;
|
||||
return { photo, day, at: moment?.at ?? null, pos: own ?? fromEntry };
|
||||
}
|
||||
|
||||
function filterFor(
|
||||
placed: readonly Placed[],
|
||||
options: PhotoPickOptions,
|
||||
byEntry: ReadonlyMap<number, PhotoPickEntry>,
|
||||
): Placed[] {
|
||||
let out = [...placed];
|
||||
if (options.entryId !== undefined) {
|
||||
const entryId = options.entryId;
|
||||
const linked = out.filter((p) => p.photo.entryIds.includes(entryId));
|
||||
const entryDay = byEntry.get(entryId)?.date;
|
||||
out = linked.length > 0 || !entryDay ? linked : out.filter((p) => p.day === entryDay);
|
||||
}
|
||||
if (options.date !== undefined) out = out.filter((p) => p.day === options.date);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Days in order (undated last); within a day, greedy places of `placeRadiusM`, the photos without one together. */
|
||||
function groupByDayAndPlace(placed: readonly Placed[]): Group[] {
|
||||
const days = new Map<string, Placed[]>();
|
||||
for (const p of [...placed].sort(byTime)) {
|
||||
const list = days.get(p.day);
|
||||
if (list) list.push(p);
|
||||
else days.set(p.day, [p]);
|
||||
}
|
||||
const out: Group[] = [];
|
||||
for (const day of [...days.keys()].sort(byDay)) {
|
||||
const located: Group[] = [];
|
||||
const nowhere: Group = { day, members: [], centre: null };
|
||||
for (const p of days.get(day) ?? []) {
|
||||
if (!p.pos) {
|
||||
nowhere.members.push(p);
|
||||
continue;
|
||||
}
|
||||
const pos = p.pos;
|
||||
const near = located.find(
|
||||
(g) => g.centre && haversineMetres(g.centre.lat, g.centre.lng, pos.lat, pos.lng) <= PHOTO_PICK.placeRadiusM,
|
||||
);
|
||||
if (!near) {
|
||||
located.push({ day, members: [p], centre: { ...pos } });
|
||||
continue;
|
||||
}
|
||||
near.members.push(p);
|
||||
const n = near.members.length;
|
||||
const c = near.centre ?? pos;
|
||||
near.centre = { lat: c.lat + (pos.lat - c.lat) / n, lng: c.lng + (pos.lng - c.lng) / n };
|
||||
}
|
||||
out.push(...located);
|
||||
if (nowhere.members.length > 0) out.push(nowhere);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Near duplicates folded onto their best, then the doubtful ones set aside unless nothing else is left. */
|
||||
function judge(group: Group, purpose: 'page' | 'cover', median: number | null): Judged {
|
||||
const sets = similarSets(group.members);
|
||||
const dropped: PhotoPickDropped[] = [];
|
||||
const kept: { p: Placed; why: string[] }[] = [];
|
||||
for (const set of sets) {
|
||||
const ranked = [...set].sort((a, b) => compare(a, b, purpose, median));
|
||||
const best = ranked[0];
|
||||
if (!best) continue;
|
||||
const why: string[] = [];
|
||||
if (set.length > 1) {
|
||||
const measured = set.filter((p) => typeof p.photo.signals?.sharpness === 'number').length;
|
||||
why.push(measured >= 2 ? `sharpest of ${set.length} similar` : `best of ${set.length} similar`);
|
||||
for (const other of ranked.slice(1)) {
|
||||
dropped.push({ photoId: other.photo.photoId, reason: 'near_duplicate_of', of: best.photo.photoId });
|
||||
}
|
||||
}
|
||||
kept.push({ p: best, why });
|
||||
}
|
||||
const minEdge = PHOTO_PICK.minLongEdge[purpose];
|
||||
const doubts = kept.map(({ p }) => doubtOf(p.photo, median, minEdge));
|
||||
const sound = kept.filter((_, i) => doubts[i] === null);
|
||||
const keepDoubtful = sound.length === 0;
|
||||
const picks: Ranked[] = [];
|
||||
kept.forEach(({ p, why }, i) => {
|
||||
const doubt = doubts[i] ?? null;
|
||||
if (doubt && !keepDoubtful) {
|
||||
dropped.push({ photoId: p.photo.photoId, reason: doubt });
|
||||
return;
|
||||
}
|
||||
if (doubt === 'possibly_blurry') why.push('possibly blurry');
|
||||
if (doubt === 'low_resolution') why.push('low resolution for print');
|
||||
picks.push({ photoId: p.photo.photoId, at: p.at, why: [...why, ...plainWhy(p.photo)] });
|
||||
});
|
||||
const rank = new Map(
|
||||
kept
|
||||
.map(({ p }) => p)
|
||||
.sort((a, b) => compare(a, b, purpose, median))
|
||||
.map((p, i) => [p.photo.photoId, i]),
|
||||
);
|
||||
picks.sort((a, b) => (rank.get(a.photoId) ?? 0) - (rank.get(b.photoId) ?? 0));
|
||||
return { picks, dropped };
|
||||
}
|
||||
|
||||
/**
|
||||
* Photos that look alike, joined transitively: a burst (taken within
|
||||
* `burstSeconds`) whose hashes, where both are measured, are within
|
||||
* `burstHashDistance`, or near-identical hashes at any time. `members` come in
|
||||
* time order (`groupByDayAndPlace`), so once a photo without a hash is past
|
||||
* its burst window no later one can match it. Every hash is read once, and a
|
||||
* pair costs two XORs, so a day of thousands of photos stays quick.
|
||||
*/
|
||||
function similarSets(members: readonly Placed[]): Placed[][] {
|
||||
const parent = members.map((_, i) => i);
|
||||
const root = (i: number): number => {
|
||||
let r = i;
|
||||
while (parent[r] !== r) r = parent[r] ?? r;
|
||||
// Every index on the way now points straight at the root.
|
||||
for (let k = i; parent[k] !== r; ) {
|
||||
const next = parent[k] ?? r;
|
||||
parent[k] = r;
|
||||
k = next;
|
||||
}
|
||||
return r;
|
||||
};
|
||||
const hashes = members.map((p) => parseHash(p.photo.signals?.phash));
|
||||
members.forEach((a, i) => {
|
||||
const ha = hashes[i] ?? null;
|
||||
for (let j = i + 1; j < members.length; j++) {
|
||||
const b = members[j];
|
||||
if (!b) continue;
|
||||
const burst = a.at !== null && b.at !== null && b.at - a.at <= PHOTO_PICK.burstSeconds;
|
||||
if (!burst && ha === null) break;
|
||||
if (alike(burst, ha, hashes[j] ?? null)) parent[root(j)] = root(i);
|
||||
}
|
||||
});
|
||||
const sets = new Map<number, Placed[]>();
|
||||
members.forEach((p, i) => sets.set(root(i), [...(sets.get(root(i)) ?? []), p]));
|
||||
return [...sets.values()];
|
||||
}
|
||||
|
||||
function alike(burst: boolean, ha: Hash | null, hb: Hash | null): boolean {
|
||||
const distance = ha && hb ? bitsApart(ha, hb) : null;
|
||||
if (burst) return distance === null || distance <= PHOTO_PICK.burstHashDistance;
|
||||
return distance !== null && distance <= PHOTO_PICK.hashDistance;
|
||||
}
|
||||
|
||||
/** Which band of `PHOTO_PICK.bands` a ratio falls in, centred on 1; zero or nothing is the lowest band. */
|
||||
function band(ratio: number, step: number): number {
|
||||
return ratio > 0 ? Math.round(Math.log(ratio) / Math.log(step)) : -Infinity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best first: for a cover a wide or square photo, then the sharper relative to
|
||||
* the journey, then fewer burnt-out or black pixels, then more pixels, then a
|
||||
* caption, then one the book does not have yet, each compared in bands so the
|
||||
* next one decides between two photos about as good. An unknown sharpness
|
||||
* counts as typical, an unknown clipping as none, an unknown size as the
|
||||
* smallest.
|
||||
*/
|
||||
function compare(a: Placed, b: Placed, purpose: 'page' | 'cover', median: number | null): number {
|
||||
const { bands } = PHOTO_PICK;
|
||||
const keys: Array<(p: Placed) => number> = [
|
||||
(p) => (purpose === 'cover' && shapeOf(p.photo) === 'portrait' ? 1 : 0),
|
||||
(p) => {
|
||||
const s = p.photo.signals?.sharpness;
|
||||
return -band(typeof s === 'number' && median ? s / median : 1, bands.sharpness);
|
||||
},
|
||||
(p) => Math.floor((p.photo.signals?.clipped ?? 0) / bands.clipped),
|
||||
(p) => -band((p.photo.signals?.origWidth ?? 0) * (p.photo.signals?.origHeight ?? 0), bands.pixels),
|
||||
(p) => (p.photo.caption?.trim() ? 0 : 1),
|
||||
(p) => (p.photo.inBook ? 1 : 0),
|
||||
];
|
||||
for (const key of keys) {
|
||||
const ka = key(a);
|
||||
const kb = key(b);
|
||||
if (ka !== kb) return ka - kb;
|
||||
}
|
||||
return byTime(a, b);
|
||||
}
|
||||
|
||||
function doubtOf(
|
||||
photo: PhotoPickCandidate,
|
||||
median: number | null,
|
||||
minEdge: number,
|
||||
): 'possibly_blurry' | 'low_resolution' | null {
|
||||
const s = photo.signals;
|
||||
if (s && typeof s.sharpness === 'number' && median !== null) {
|
||||
if (s.sharpness < median * PHOTO_PICK.blurRatio && s.sharpness < PHOTO_PICK.blurFloor) return 'possibly_blurry';
|
||||
}
|
||||
if (s?.origWidth && s.origHeight && Math.max(s.origWidth, s.origHeight) < minEdge) return 'low_resolution';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The photo's shape: from its row's size (drawn upright), else from the original's size the server measured. */
|
||||
function shapeOf(photo: PhotoPickCandidate): 'landscape' | 'portrait' | 'square' | null {
|
||||
const own = photo.width && photo.height;
|
||||
const w = own ? photo.width : photo.signals?.origWidth;
|
||||
const h = own ? photo.height : photo.signals?.origHeight;
|
||||
if (!w || !h) return null;
|
||||
const ratio = w / h;
|
||||
if (ratio >= 1.1) return 'landscape';
|
||||
return ratio <= 0.9 ? 'portrait' : 'square';
|
||||
}
|
||||
|
||||
function plainWhy(photo: PhotoPickCandidate): string[] {
|
||||
const why: string[] = [];
|
||||
const shape = shapeOf(photo);
|
||||
if (shape) why.push(shape);
|
||||
why.push(photo.inBook ? 'in the book already' : 'not in the book yet');
|
||||
if (photo.caption?.trim()) why.push('has a caption');
|
||||
return why;
|
||||
}
|
||||
|
||||
/** Up to `limit` picks, one from each group in turn, so every day and place gets its best before any gets a second. */
|
||||
function takeRoundRobin(lists: readonly Ranked[][], limit: number): Ranked[][] {
|
||||
const out = lists.map((): Ranked[] => []);
|
||||
let taken = 0;
|
||||
for (let round = 0; taken < limit; round++) {
|
||||
let tookOne = false;
|
||||
for (let i = 0; i < lists.length && taken < limit; i++) {
|
||||
const next = lists[i]?.[round];
|
||||
if (!next) continue;
|
||||
out[i]?.push(next);
|
||||
taken++;
|
||||
tookOne = true;
|
||||
}
|
||||
if (!tookOne) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* A group's place and entry: the entry most of its photos are linked to, else
|
||||
* the nearest entry location within `labelRadiusM` (its entry only when it is
|
||||
* of the same day). A group without a place is named after its one entry, if
|
||||
* all its photos share one.
|
||||
*/
|
||||
function labelOf(group: Group, entries: readonly PhotoPickEntry[]): { place: string | null; entryId: number | null } {
|
||||
const counts = new Map<number, number>();
|
||||
for (const m of group.members) for (const id of m.photo.entryIds) counts.set(id, (counts.get(id) ?? 0) + 1);
|
||||
const linked = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0] - b[0])[0]?.[0] ?? null;
|
||||
const linkedEntry = linked === null ? undefined : entries.find((e) => e.id === linked);
|
||||
const centre = group.centre;
|
||||
if (!centre) {
|
||||
const all = linked !== null && group.members.every((m) => m.photo.entryIds.includes(linked));
|
||||
return { place: all ? (linkedEntry?.location ?? null) : null, entryId: linked };
|
||||
}
|
||||
let nearest: { entry: PhotoPickEntry; d: number } | null = null;
|
||||
for (const e of entries) {
|
||||
if (e.lat === null || e.lng === null) continue;
|
||||
const d = haversineMetres(centre.lat, centre.lng, e.lat, e.lng);
|
||||
if (d <= PHOTO_PICK.labelRadiusM && (!nearest || d < nearest.d)) nearest = { entry: e, d };
|
||||
}
|
||||
const name = nearest ? (nearest.entry.location ?? nearest.entry.title) : null;
|
||||
const sameDay = nearest && nearest.entry.date === group.day ? nearest.entry.id : null;
|
||||
return { place: name ?? linkedEntry?.location ?? null, entryId: linked ?? sameDay };
|
||||
}
|
||||
|
||||
/** Days in calendar order, `undated` last. */
|
||||
function byDay(a: string, b: string): number {
|
||||
if (a === 'undated' || b === 'undated') return Number(a === 'undated') - Number(b === 'undated');
|
||||
return a < b ? -1 : 1;
|
||||
}
|
||||
|
||||
/** Something taken at a time: a pick (its own id) or a placed photo (its photo's). */
|
||||
interface Timed {
|
||||
at: number | null;
|
||||
photoId?: number;
|
||||
photo?: { photoId: number };
|
||||
}
|
||||
|
||||
function byTime(a: Timed, b: Timed): number {
|
||||
if (a.at !== null && b.at !== null && a.at !== b.at) return a.at - b.at;
|
||||
if (a.at === null && b.at !== null) return 1;
|
||||
if (a.at !== null && b.at === null) return -1;
|
||||
return (a.photoId ?? a.photo?.photoId ?? 0) - (b.photoId ?? b.photo?.photoId ?? 0);
|
||||
}
|
||||
Reference in New Issue
Block a user