mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
feat: carry annotations in views and aim tilted views at the area
A view can carry the marks the app's annotate_map action draws, and links carry them in the an parameter, bounded in count and size. A tilted view of an area now places the camera behind its center so the area stays in the middle of the frame.
This commit is contained in:
1 parent
af8856cedf
commit
f68aa988ec
5 files changed
+265
-4
No files matched your search
@@ -63,6 +63,26 @@ const FOLLOWABLE = Object.freeze({
|
||||
|
||||
export const VIEW_FOLLOW_KINDS = Object.freeze(Object.keys(FOLLOWABLE));
|
||||
|
||||
/** Annotation kinds the app draws, as in its annotate_map action. */
|
||||
export const VIEW_ANNOTATION_TYPES = Object.freeze([
|
||||
'pin',
|
||||
'highlight',
|
||||
'area',
|
||||
'arrow',
|
||||
'route',
|
||||
'label',
|
||||
]);
|
||||
const ANNOTATION_COLORS = Object.freeze([
|
||||
'primary',
|
||||
'amber',
|
||||
'cyan',
|
||||
'green',
|
||||
'red',
|
||||
]);
|
||||
const MAX_ANNOTATIONS = 24;
|
||||
const MAX_ANNOTATION_PARAM_CHARS = 6000;
|
||||
const ANNOTATION_PARAM = 'an';
|
||||
|
||||
const MIN_ALTITUDE_M = 50;
|
||||
const MAX_ALTITUDE_M = 20_000_000;
|
||||
|
||||
@@ -103,6 +123,43 @@ export const VIEW_PROPERTIES = Object.freeze({
|
||||
enum: [...VIEW_MAPS],
|
||||
description: 'Map imagery; photoreal is Google 3D tiles.',
|
||||
}),
|
||||
annotations: Object.freeze({
|
||||
type: 'array',
|
||||
maxItems: MAX_ANNOTATIONS,
|
||||
description:
|
||||
'Marks drawn on the globe: a pin, highlight, area or label at a place ' +
|
||||
'name (target) or lat/lon, an arrow between two points, or a route ' +
|
||||
'through points.',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: { type: 'string', enum: [...VIEW_ANNOTATION_TYPES] },
|
||||
target: { type: 'string', minLength: 1, maxLength: 200 },
|
||||
latitude: { type: 'number', minimum: -90, maximum: 90 },
|
||||
longitude: { type: 'number', minimum: -180, maximum: 180 },
|
||||
toLatitude: { type: 'number', minimum: -90, maximum: 90 },
|
||||
toLongitude: { type: 'number', minimum: -180, maximum: 180 },
|
||||
points: {
|
||||
type: 'array',
|
||||
minItems: 2,
|
||||
maxItems: 12,
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
latitude: { type: 'number', minimum: -90, maximum: 90 },
|
||||
longitude: { type: 'number', minimum: -180, maximum: 180 },
|
||||
},
|
||||
required: ['latitude', 'longitude'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
label: { type: 'string', maxLength: 120 },
|
||||
color: { type: 'string', enum: [...ANNOTATION_COLORS] },
|
||||
},
|
||||
required: ['type'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
}),
|
||||
follow: Object.freeze({
|
||||
type: 'object',
|
||||
description:
|
||||
@@ -119,6 +176,59 @@ export const VIEW_PROPERTIES = Object.freeze({
|
||||
});
|
||||
|
||||
const round = (value, digits) => Number(value.toFixed(digits));
|
||||
const latitude = (value) =>
|
||||
Number.isFinite(value) && Math.abs(value) <= 90 ? value : undefined;
|
||||
const longitude = (value) =>
|
||||
Number.isFinite(value) && Math.abs(value) <= 180 ? value : undefined;
|
||||
const text = (value, max) =>
|
||||
typeof value === 'string' && value.trim()
|
||||
? value.trim().slice(0, max)
|
||||
: undefined;
|
||||
|
||||
/**
|
||||
* One annotation with only the fields the app draws, or null when it has
|
||||
* nothing to place. Unknown fields and out-of-range values are dropped.
|
||||
*/
|
||||
function annotationOf(input) {
|
||||
if (!input || !VIEW_ANNOTATION_TYPES.includes(input.type)) return null;
|
||||
const points = Array.isArray(input.points)
|
||||
? input.points
|
||||
.slice(0, 12)
|
||||
.map((point) => ({
|
||||
latitude: latitude(point?.latitude),
|
||||
longitude: longitude(point?.longitude),
|
||||
}))
|
||||
.filter(
|
||||
(point) =>
|
||||
point.latitude !== undefined && point.longitude !== undefined,
|
||||
)
|
||||
: [];
|
||||
// Coordinates are kept or dropped in pairs; half a position places nothing.
|
||||
const pair = (lat, lon) =>
|
||||
latitude(lat) !== undefined && longitude(lon) !== undefined
|
||||
? [lat, lon]
|
||||
: [undefined, undefined];
|
||||
const [lat, lon] = pair(input.latitude, input.longitude);
|
||||
const [toLat, toLon] = pair(input.toLatitude, input.toLongitude);
|
||||
const entry = Object.fromEntries(
|
||||
Object.entries({
|
||||
type: input.type,
|
||||
target: text(input.target, 200),
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
toLatitude: toLat,
|
||||
toLongitude: toLon,
|
||||
points: points.length >= 2 ? points : undefined,
|
||||
label: text(input.label, 120),
|
||||
color: ANNOTATION_COLORS.includes(input.color) ? input.color : undefined,
|
||||
}).filter(([, value]) => value !== undefined),
|
||||
);
|
||||
const placed =
|
||||
entry.target !== undefined ||
|
||||
entry.points !== undefined ||
|
||||
(entry.latitude !== undefined && entry.longitude !== undefined);
|
||||
return placed ? Object.freeze(entry) : null;
|
||||
}
|
||||
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
|
||||
|
||||
/**
|
||||
@@ -132,6 +242,7 @@ export function createView({
|
||||
style = null,
|
||||
map = null,
|
||||
follow = null,
|
||||
annotations = [],
|
||||
} = {}) {
|
||||
if (!Number.isFinite(camera?.lat) || !Number.isFinite(camera?.lon))
|
||||
throw new TypeError('A view needs a camera lat and lon');
|
||||
@@ -167,6 +278,12 @@ export function createView({
|
||||
id: String(follow.id).toLowerCase(),
|
||||
})
|
||||
: null,
|
||||
annotations: Object.freeze(
|
||||
(Array.isArray(annotations) ? annotations : [])
|
||||
.slice(0, MAX_ANNOTATIONS)
|
||||
.map(annotationOf)
|
||||
.filter(Boolean),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -194,9 +311,28 @@ export function viewToParams(view) {
|
||||
};
|
||||
encodeLayerStateParams(params, state);
|
||||
}
|
||||
if (view.annotations.length) {
|
||||
const encoded = JSON.stringify(view.annotations);
|
||||
if (encoded.length <= MAX_ANNOTATION_PARAM_CHARS)
|
||||
params.set(ANNOTATION_PARAM, encoded);
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
/** The annotations a share link carries, or an empty list. */
|
||||
export function annotationsFromParams(params) {
|
||||
const raw = params.get(ANNOTATION_PARAM);
|
||||
if (!raw || raw.length > MAX_ANNOTATION_PARAM_CHARS) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed)
|
||||
? parsed.slice(0, MAX_ANNOTATIONS).map(annotationOf).filter(Boolean)
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** The view a set of share-link hash parameters restores, or null. */
|
||||
export function viewFromParams(params) {
|
||||
const lat = Number.parseFloat(params.get('lat'));
|
||||
@@ -226,6 +362,7 @@ export function viewFromParams(params) {
|
||||
style: STYLES_BY_URL_NAME[params.get('style')] ?? null,
|
||||
map: VIEW_MAPS.includes(map) ? map : null,
|
||||
follow,
|
||||
annotations: annotationsFromParams(params),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -77,3 +77,52 @@ test('views use the same style names as the app share links', async () => {
|
||||
assert.match(source, /const STYLE_TO_URL = STYLE_URL_NAMES;/);
|
||||
assert.equal(STYLE_URL_NAMES.retro, 'crt');
|
||||
});
|
||||
|
||||
test('annotations travel in the link, keeping only what the app draws', async () => {
|
||||
const { annotationsFromParams } = await import('./index.js');
|
||||
const view = createView({
|
||||
camera: { lat: 25, lon: 121 },
|
||||
annotations: [
|
||||
{ type: 'pin', target: 'Taipei 101', label: 'Taipei 101', color: 'red' },
|
||||
{
|
||||
type: 'arrow',
|
||||
latitude: 24,
|
||||
longitude: 119,
|
||||
toLatitude: 25,
|
||||
toLongitude: 121,
|
||||
},
|
||||
{
|
||||
type: 'route',
|
||||
points: [
|
||||
{ latitude: 1, longitude: 2 },
|
||||
{ latitude: 3, longitude: 4 },
|
||||
],
|
||||
screenX: 0.5,
|
||||
},
|
||||
{ type: 'pin' },
|
||||
{ type: 'sparkle', target: 'x' },
|
||||
{ type: 'label', latitude: 200, longitude: 0, target: 'Here' },
|
||||
],
|
||||
});
|
||||
assert.equal(view.annotations.length, 4);
|
||||
assert.equal(view.annotations[2].screenX, undefined);
|
||||
assert.deepEqual(view.annotations[3], { type: 'label', target: 'Here' });
|
||||
const params = viewToParams(view);
|
||||
assert.ok(params.has('an'));
|
||||
assert.deepEqual(viewFromParams(params), view);
|
||||
assert.deepEqual(
|
||||
annotationsFromParams(new URLSearchParams('an=not json')),
|
||||
[],
|
||||
);
|
||||
const many = createView({
|
||||
camera: { lat: 0, lon: 0 },
|
||||
annotations: Array.from({ length: 30 }, () => ({
|
||||
type: 'label',
|
||||
target: 'x'.repeat(200),
|
||||
label: 'y'.repeat(120),
|
||||
})),
|
||||
});
|
||||
assert.equal(many.annotations.length, 24);
|
||||
// Too long for a link: the view keeps them, the link leaves them out.
|
||||
assert.equal(viewToParams(many).has('an'), false);
|
||||
});
|
||||
Reference in new issue
Block a user