Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 14 additions & 6 deletions src/api/Common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ export interface XMLHttpRequestHeader {
value: string,
}

const POP_SHARDED_FBCDN_HOST =
/^https:\/\/(external|scontent)-[^.]+\.xx\.fbcdn\.net/;

export function normalizeFbCdnUrl(url: string): string {
return url.replace(POP_SHARDED_FBCDN_HOST, "https://$1.xx.fbcdn.net");
}

/**
* Decompress and parse an array buffer containing zipped
* json data and return as a json object.
Expand Down Expand Up @@ -67,10 +74,11 @@ export function xhrFetch<TResult>(
body?: string,
abort?: Promise<void>): Promise<TResult> {

const requestUrl = normalizeFbCdnUrl(url);
const xhr = new XMLHttpRequest();
const promise = new Promise<TResult>(
(resolve, reject) => {
xhr.open(method, url, true);
xhr.open(method, requestUrl, true);
for (const header of headers) {
xhr.setRequestHeader(header.name, header.value);
}
Expand All @@ -80,25 +88,25 @@ export function xhrFetch<TResult>(
xhr.onload = () => {
if (xhr.status !== 200) {
const error = xhr.response ??
new MapillaryError(`Response status error: ${url}`);
new MapillaryError(`Response status error: ${requestUrl}`);
reject(error);
}

if (!xhr.response) {
reject(new MapillaryError(`Response empty: ${url}`));
reject(new MapillaryError(`Response empty: ${requestUrl}`));
}

resolve(xhr.response);
};

xhr.onerror = () => {
reject(new MapillaryError(`Request error: ${url}`));
reject(new MapillaryError(`Request error: ${requestUrl}`));
};
xhr.ontimeout = () => {
reject(new MapillaryError(`Request timeout: ${url}`));
reject(new MapillaryError(`Request timeout: ${requestUrl}`));
};
xhr.onabort = () => {
reject(new MapillaryError(`Request aborted: ${url}`));
reject(new MapillaryError(`Request aborted: ${requestUrl}`));
};
xhr.send(method === "POST" ? body : null);
});
Expand Down
5 changes: 5 additions & 0 deletions src/api/ents/SpatialImageEnt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ export interface SpatialImageEnt extends CameraEnt, IDEnt {
*/
captured_at: number;

/**
* IANA timezone at the image capture location.
*/
timezone?: string;

/**
* Original EXIF compass angle, measured in degrees.
*/
Expand Down
1 change: 1 addition & 0 deletions src/api/provider/GraphQueryCreator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export class GraphQueryCreator {
'sfm_cluster',
'thumb_1024_url',
'thumb_2048_url',
'timezone',
'width',
];
this.imageTileFields = ['url', 'z', 'x', 'y'];
Expand Down
60 changes: 46 additions & 14 deletions src/component/attribution/AttributionComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,37 @@ import { Component } from "../Component";
import { ComponentConfiguration } from "../interfaces/ComponentConfiguration";
import { ComponentName } from "../ComponentName";

export function formatCaptureDate(
capturedAt: number,
compact: boolean,
locales?: string | string[],
timeZone?: string): string {
const date = new Date(capturedAt);

if (isNaN(date.getTime())) {
return date.toString();
}

const options: Intl.DateTimeFormatOptions = compact ?
{ year: "numeric" } :
{ day: "numeric", month: "short", year: "numeric" };

if (timeZone) {
options.timeZone = timeZone;
}

try {
return new Intl.DateTimeFormat(
compact ? "en-US" : locales,
options).format(date);
} catch {
options.timeZone = "UTC";
return new Intl.DateTimeFormat(
compact ? "en-US" : locales,
options).format(date);
}
}

export class AttributionComponent extends Component<ComponentConfiguration> {
public static componentName: ComponentName = "attribution";

Expand All @@ -28,6 +59,7 @@ export class AttributionComponent extends Component<ComponentConfiguration> {
exploreUrl,
image.id,
image.capturedAt,
image.timezone,
size.width);
return {
name: this._name,
Expand All @@ -54,11 +86,12 @@ export class AttributionComponent extends Component<ComponentConfiguration> {
exploreUrl: string,
imageId: string,
capturedAt: number,
timeZone: string | undefined,
viewportWidth: number)
: vd.VNode {
const compact = viewportWidth <= 640;

const date = this._makeDate(capturedAt, compact);
const date = [this._makeDate(capturedAt, compact, timeZone)];
const by = this._makeBy(creatorUsername, exploreUrl, imageId, compact);

const compactClass = compact ?
Expand All @@ -67,7 +100,7 @@ export class AttributionComponent extends Component<ComponentConfiguration> {
return vd.h(
"div.mapillary-attribution-container" + compactClass,
{},
[...by, date]);
[...by, ...date]);
}

private _makeBy(
Expand Down Expand Up @@ -152,20 +185,19 @@ export class AttributionComponent extends Component<ComponentConfiguration> {
return [image];
}

private _makeDate(capturedAt: number, compact: boolean): vd.VNode {
const date = new Date(capturedAt)
.toDateString()
.split(" ");

const formatted = (date.length > 3 ?
compact ?
[date[3]] :
[date[1], date[2] + ",", date[3]] :
date).join(" ");

private _makeDate(
capturedAt: number,
compact: boolean,
timeZone?: string): vd.VNode {
return vd.h(
"div.mapillary-attribution-date",
{ textContent: formatted },
{
textContent: formatCaptureDate(
capturedAt,
compact,
undefined,
timeZone),
},
[]);
}
}
36 changes: 30 additions & 6 deletions src/component/bearing/BearingComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { ViewportSize } from "../../render/interfaces/ViewportSize";
import { VirtualNodeHash } from "../../render/interfaces/VirtualNodeHash";
import { AnimationFrame } from "../../state/interfaces/AnimationFrame";
import { ComponentSize } from "../util/ComponentSize";
import { createTooltipProperties } from "../util/Tooltip";
import { Container } from "../../viewer/Container";
import { Navigator } from "../../viewer/Navigator";
import { isSpherical } from "../../geo/Geo";
Expand All @@ -49,6 +50,31 @@ interface ImageFovOperation {
(state: ImageFovState): ImageFovState;
}

/**
* Create the bearing indicator container.
*
* @description virtual-dom writes the selector class names into the
* properties object passed to `h`. The properties must therefore be
* created for every render; a reused object makes the previous and next
* properties identical, so class changes (e.g. switching to and from the
* compact size when the viewer is resized) are never patched.
*
* @ignore
*/
export function createBearingContainer(
compact: boolean,
children: vd.VNode[]): vd.VNode {

return vd.h(
"div.mapillary-bearing-indicator-container" +
(compact ? ".mapillary-bearing-compact" : ""),
createTooltipProperties(
"Bearing",
{ oncontextmenu: (event: MouseEvent): void => { event.preventDefault(); } },
"left"),
children);
}

/**
* @class BearingComponent
*
Expand Down Expand Up @@ -268,15 +294,13 @@ export class BearingComponent extends Component<BearingConfiguration> {
const cameraSector: vd.VNode = this._createCircleSectorCompass(
this._createCircleSector(Math.max(Math.PI / 20, cf), "#FFF"));

const compact: string = configuration.size === ComponentSize.Small ||
configuration.size === ComponentSize.Automatic && size.width < 640 ?
".mapillary-bearing-compact" : "";
const compact: boolean = configuration.size === ComponentSize.Small ||
configuration.size === ComponentSize.Automatic && size.width < 640;

return {
name: this._name,
vNode: vd.h(
"div.mapillary-bearing-indicator-container" + compact,
{ oncontextmenu: (event: MouseEvent): void => { event.preventDefault(); } },
vNode: createBearingContainer(
compact,
[
background,
fovIndicator,
Expand Down
25 changes: 25 additions & 0 deletions src/component/interfaces/PointerConfiguration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,29 @@ export interface PointerConfiguration extends ComponentConfiguration {
* @default true
*/
touchZoom?: boolean;

/**
* Keep drag panning, and the bounce back after panning or zooming,
* within a view window of the current image instead of its edges.
*
* @description The window is a perspective frustum from the render
* camera's position and up vector, looking at `center` with the
* vertical `fieldOfView`, at the viewport's aspect. Regular images
* are additionally limited to their edges. `null` restores the image
* edges.
*
* @default null
*/
viewBounds?: PointerViewBounds | null;
}

/**
* A view window of the current image for the pointer component.
*/
export interface PointerViewBounds {
/** Window center in basic coordinates of the current image. */
center: number[];

/** Vertical field of view of the window, in degrees. */
fieldOfView: number;
}
85 changes: 85 additions & 0 deletions src/component/interfaces/SliderConfiguration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,91 @@ export interface SliderConfigurationIds {
* ```
*/
export interface SliderConfiguration extends ComponentConfiguration {
/**
* Keep stationary comparison cameras aligned to gravity.
*
* @default false
*/
gravityAligned?: boolean;
/**
* Reproject the background into the foreground's live world view.
*
* @description Opt-in for calibrated common-view comparisons. Preserves
* the original foreground camera and each image's lens projection. Use
* Stationary mode; the caller must validate shared coverage, crop the
* view, and prevent interaction into uncovered regions. Overrides
* gravityAligned camera leveling for this comparison.
*
* @default false
*/
viewAligned?: boolean;
/**
* Opt in to guarded, interactive 3D translation for viewAligned pairs.
*
* @description Only hydrated perspective pairs in the same nonempty merged
* component, with genuine meshes, valid poses/scales, <=2m baseline,
* <=10-degree sampled hit parallax and shared photo/mesh coverage can use Motion.
* Unsafe or unavailable data always uses Stationary; mode controls cannot
* override rejection. Explicit Stationary remains manual 2D. Coverage is a
* conservative bounded heuristic, not a mathematical guarantee.
* @default false
*/
automaticMotion?: boolean;
/**
* Image-based refinement of the background's reconstructed orientation.
*
* @description World-frame rotation quaternion [x, y, z, w] that maps the
* background's reconstructed viewing rays onto the foreground's, e.g.
* estimated by matching both photos. Applies to viewAligned Stationary
* rendering, where it replaces the roll safeguard while set, and to
* panorama-to-panorama look-around, where it replaces the compass
* heading alignment while set. Invalid values are ignored.
*
* @default undefined
*/
viewCorrection?: number[];
/**
* Widening of a panorama background in viewAligned rendering.
*
* @description Ratio applied to the background's tan(fov / 2) relative
* to the live view, about the view centre. A comparison panorama
* captured ahead of the original along the view shows the subject
* larger; widening it (>1) shows the subject at the original's size,
* and narrowing (<1) compensates one captured behind. Applies only to
* viewAligned pairs whose background is a panorama; values outside
* [0.25, 4] are ignored (1).
*
* @default 1
*/
viewScale?: number;
/**
* Gate policy for automaticMotion.
*
* @description "strict" (default) requires one shared merge component,
* <=2m baseline, <=10-degree parallax and full sampled coverage.
* "relaxed" also accepts pairs from different merged
* reconstructions placed by their computed geo-poses, with <=12m
* baseline, <=35-degree parallax, and per stop >=50% of sampled rays
* inside both photos with both meshes agreeing on depth within 2x.
* Expect registration seams and some warping in relaxed mode.
* @default "strict"
*/
automaticMotionPolicy?: "strict" | "relaxed";
/**
* Clip the foreground at a fraction of the visible viewport width.
*
* @description Keeps the split independent of image projection, pan,
* horizon leveling and zoom. Image-coordinate clipping remains the default.
* @default false
*/
screenSpace?: boolean;
/**
* Soft edge for the screenSpace curtain: half width of the blend around
* the split, as a fraction of the viewport width (0 to 0.1). 0 keeps
* the hard split.
* @default 0
*/
curtainFeather?: number;
/**
* Initial position of the slider on the interval [0, 1].
*
Expand Down
2 changes: 1 addition & 1 deletion src/component/keyboard/KeyZoomHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export class KeyZoomHandler extends HandlerBase<KeyboardConfiguration> {
const unprojected: THREE.Vector3 = this._viewportCoords.unprojectFromViewport(0, 0, render.perspective);
const reference: number[] = transform.projectBasic(unprojected.toArray());

this._navigator.stateService.zoomIn(delta, reference);
this._navigator.stateService.stepZoom(delta, reference);
});
}

Expand Down
Loading
Loading