Skip to content

Commit

Permalink
Merge pull request #7209 from TerriaJS/cog-support-cesium-only-1
Browse files Browse the repository at this point in the history
Cog support cesium only 1
  • Loading branch information
na9da authored Oct 22, 2024
2 parents 4a1e0db + 5ef37a3 commit dc3ec3c
Show file tree
Hide file tree
Showing 15 changed files with 553 additions and 15 deletions.
1 change: 1 addition & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

#### next release (8.7.8)

- Add support for Cloud Optimised Geotiff (cog) in Cesium mode. Currently supports EPSG 4326 and 3857. There is experimental support for other projections but performance might suffer and there could be other issues.
- Fix `Workbench.collapseAll()` and `Workbench.expandAll()` for References.
- [The next improvement]

Expand Down
5 changes: 5 additions & 0 deletions lib/Core/getDataType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,10 @@ const builtinRemoteDataTypes: RemoteDataType[] = [
value: "json",
name: "core.dataType.json"
},
{
value: "cog",
name: "core.dataType.cog"
},
{
value: "i3s",
name: "core.dataType.i3s"
Expand Down Expand Up @@ -191,6 +195,7 @@ const builtinLocalDataTypes: LocalDataType[] = [
name: "core.dataType.shp",
extensions: ["zip"]
}

// Add next builtin local upload type
];

Expand Down
243 changes: 243 additions & 0 deletions lib/Models/Catalog/CatalogItems/CogCatalogItem.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
import i18next from "i18next";
import {
computed,
makeObservable,
observable,
onBecomeObserved,
onBecomeUnobserved,
runInAction
} from "mobx";
import {
GeographicTilingScheme,
WebMercatorTilingScheme
} from "terriajs-cesium";
import CesiumMath from "terriajs-cesium/Source/Core/Math";
import type TIFFImageryProvider from "terriajs-tiff-imagery-provider";
import CatalogMemberMixin from "../../../ModelMixins/CatalogMemberMixin";
import MappableMixin, { MapItem } from "../../../ModelMixins/MappableMixin";
import CogCatalogItemTraits from "../../../Traits/TraitsClasses/CogCatalogItemTraits";
import { RectangleTraits } from "../../../Traits/TraitsClasses/MappableTraits";
import CreateModel from "../../Definition/CreateModel";
import LoadableStratum from "../../Definition/LoadableStratum";
import { BaseModel } from "../../Definition/Model";
import StratumFromTraits from "../../Definition/StratumFromTraits";
import StratumOrder from "../../Definition/StratumOrder";
import Terria from "../../Terria";
import proxyCatalogItemUrl from "../proxyCatalogItemUrl";

/**
* Loadable stratum for overriding CogCatalogItem traits
*/
class CogLoadableStratum extends LoadableStratum(CogCatalogItemTraits) {
static stratumName = "cog-loadable-stratum";

constructor(readonly model: CogCatalogItem) {
super();
makeObservable(this);
}

duplicateLoadableStratum(model: BaseModel): this {
return new CogLoadableStratum(model as CogCatalogItem) as this;
}

@computed
get shortReport(): string | undefined {
return this.model.terria.currentViewer.type === "Leaflet"
? // Warn for 2D mode
i18next.t("models.commonModelErrors.3dTypeIn2dMode", this)
: this.model._imageryProvider?.tilingScheme &&
// Show warning for experimental reprojection freature if not using EPSG 3857 or 4326
isCustomTilingScheme(this.model._imageryProvider?.tilingScheme)
? i18next.t("models.cogCatalogItem.experimentalReprojectionWarning", this)
: undefined;
}

@computed
get rectangle(): StratumFromTraits<RectangleTraits> | undefined {
const rectangle = this.model._imageryProvider?.rectangle;
if (!rectangle) {
return;
}

const { west, south, east, north } = rectangle;
return {
west: CesiumMath.toDegrees(west),
south: CesiumMath.toDegrees(south),
east: CesiumMath.toDegrees(east),
north: CesiumMath.toDegrees(north)
};
}
}

StratumOrder.addLoadStratum(CogLoadableStratum.stratumName);

/**
* Creates a Cloud Optimised Geotiff catalog item.
*
* Currently it can render EPSG 4326/3857 COG files. There is experimental
* support for other projections, however it is less performant and could have
* unknown issues.
*/
export default class CogCatalogItem extends MappableMixin(
CatalogMemberMixin(CreateModel(CogCatalogItemTraits))
) {
static readonly type = "cog";

/**
* Private imageryProvider instance. This is set once forceLoadMapItems is
* called.
*/
@observable
_imageryProvider: TIFFImageryProvider | undefined;

/**
* The reprojector function to use for reprojecting non native projections
*
* Exposed here as instance variable for stubbing in specs.
*/
reprojector = reprojector;

get type() {
return CogCatalogItem.type;
}

constructor(
id: string | undefined,
terria: Terria,
sourceReference?: BaseModel | undefined
) {
super(id, terria, sourceReference);
makeObservable(this);
this.strata.set(
CogLoadableStratum.stratumName,
new CogLoadableStratum(this)
);

// Destroy the imageryProvider when `mapItems` is no longer consumed. This
// is so that the webworkers and other resources created by the
// imageryProvider can be freed. Ideally, there would be a more explicit
// `destroy()` method in Terria life-cycle so that we don't have to rely on
// mapItems becoming observed or unobserved.
onBecomeUnobserved(this, "mapItems", () => {
if (this._imageryProvider) {
this._imageryProvider.destroy();
this._imageryProvider = undefined;
}
});

// Re-create the imageryProvider if `mapItems` is consumed again after we
// destroyed it
onBecomeObserved(this, "mapItems", () => {
if (!this._imageryProvider && !this.isLoadingMapItems) {
this.loadMapItems(true);
}
});
}

protected async forceLoadMapItems(): Promise<void> {
if (!this.url) {
return;
}
const url = proxyCatalogItemUrl(this, this.url);
const imageryProvider = await this.createImageryProvider(url);
runInAction(() => {
this._imageryProvider = imageryProvider;
});
}

@computed get mapItems(): MapItem[] {
const imageryProvider = this._imageryProvider;
if (!imageryProvider) {
return [];
}

return [
{
show: this.show,
alpha: this.opacity,
// The 'requestImage' method in Cesium's ImageryProvider has a return type that is stricter than necessary.
// In our custom ImageryProvider, we return ImageData, which is also a valid return type.
// However, since the current Cesium type definitions do not reflect this flexibility, we use a TypeScript ignore comment ('@ts-ignore')
// to suppress the type checking error. This is a temporary solution until the type definitions in Cesium are updated to accommodate ImageData.
// @ts-expect-error - The return type of 'requestImage' method in our custom ImageryProvider can be ImageData, which is not currently allowed in Cesium's type definitions, but is fine.
imageryProvider,
clippingRectangle: this.cesiumRectangle
}
];
}

/**
* Create TIFFImageryProvider for the given url.
*/
private async createImageryProvider(
url: string
): Promise<TIFFImageryProvider> {
// lazy load the imagery provider, only when needed
const [{ default: TIFFImageryProvider }, { default: proj4 }] =
await Promise.all([
import("terriajs-tiff-imagery-provider"),
import("proj4-fully-loaded")
]);

return runInAction(() =>
TIFFImageryProvider.fromUrl(url, {
credit: this.credit,
tileSize: this.tileSize,
maximumLevel: this.maximumLevel,
minimumLevel: this.minimumLevel,
enablePickFeatures: this.allowFeaturePicking,
hasAlphaChannel: this.hasAlphaChannel,
// used for reprojecting from an unknown projection to 4326/3857
// note that this is experimental and could be slow as it runs on the main thread
projFunc: this.reprojector(proj4),
// make sure we omit `undefined` options so as not to override the library defaults
renderOptions: omitUndefined({
nodata: this.renderOptions.nodata,
convertToRGB: this.renderOptions.convertToRGB,
resampleMethod: this.renderOptions.resampleMethod
})
})
);
}
}

/**
* Function returning a custom reprojector
*/
function reprojector(proj4: any) {
return (code: number) => {
if (![4326, 3857, 900913].includes(code)) {
try {
const prj = proj4("EPSG:4326", `EPSG:${code}`);
if (prj)
return {
project: prj.forward,
unproject: prj.inverse
};
} catch (e) {
console.error(e);
}
}
};
}

/**
* Returns true if the tilingScheme is custom
*/
function isCustomTilingScheme(tilingScheme: Object) {
// The upstream library defines a TIFFImageryTillingScheme but it is not
// exported so we have to check if it is not one of the standard Cesium
// tiling schemes. Also, because TIFFImageryTillingScheme derives from
// WebMercatorTilingScheme, we cannot simply do an `instanceof` check, we
// compare the exact constructor instead.
return (
tilingScheme.constructor !== WebMercatorTilingScheme &&
tilingScheme.constructor !== GeographicTilingScheme
);
}

function omitUndefined(obj: Object) {
return Object.fromEntries(
Object.entries(obj).filter(([_, value]) => value !== undefined)
);
}
10 changes: 8 additions & 2 deletions lib/Models/Catalog/registerCatalogMembers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import WebProcessingServiceCatalogFunctionJob from "./Ows/WebProcessingServiceCa
import WebProcessingServiceCatalogGroup from "./Ows/WebProcessingServiceCatalogGroup";
import SdmxJsonCatalogGroup from "./SdmxJson/SdmxJsonCatalogGroup";
import SdmxJsonCatalogItem from "./SdmxJson/SdmxJsonCatalogItem";
import CogCatalogItem from "./CatalogItems/CogCatalogItem";

export default function registerCatalogMembers() {
CatalogMemberFactory.register(CatalogGroup.type, CatalogGroup);
Expand Down Expand Up @@ -241,6 +242,7 @@ export default function registerCatalogMembers() {
UrlTemplateImageryCatalogItem
);
CatalogMemberFactory.register(AssImpCatalogItem.type, AssImpCatalogItem);
CatalogMemberFactory.register(CogCatalogItem.type, CogCatalogItem);

UrlToCatalogMemberMapping.register(
matchesExtension("csv"),
Expand Down Expand Up @@ -287,6 +289,10 @@ export default function registerCatalogMembers() {
matchesExtension("zip"),
ShapefileCatalogItem.type
);
UrlToCatalogMemberMapping.register(
matchesExtension("tif", "tiff", "geotiff"),
CogCatalogItem.type
);

// These items work by trying to match a URL, then loading the data. If it fails, they move on.
UrlToCatalogMemberMapping.register(
Expand Down Expand Up @@ -412,8 +418,8 @@ function matchesUrl(regex: RegExp) {
return /./.test.bind(regex);
}

export function matchesExtension(extension: string) {
const regex = new RegExp("\\." + extension + "$", "i");
export function matchesExtension(...extensions: string[]) {
const regex = new RegExp("\\.(" + extensions.join("|") + ")$", "i");
return function (url: string) {
return Boolean(url.match(regex));
};
Expand Down
22 changes: 13 additions & 9 deletions lib/Models/Leaflet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ export default class Leaflet extends GlobeOrMap {
map.boxZoom,
map.keyboard,
map.dragging,
map.tap
map.tapHold
]);
const pickLocation = this.pickLocation.bind(this);
const pickFeature = (entity: Entity, event: L.LeafletMouseEvent) => {
Expand Down Expand Up @@ -495,11 +495,9 @@ export default class Leaflet extends GlobeOrMap {
): Promise<void> {
if (!isDefined(target)) {
return Promise.resolve();
//throw new DeveloperError("target is required.");
}
let bounds;

// Target is a KML data source
if (isDefined(target.entities)) {
if (isDefined(this.dataSourceDisplay)) {
bounds = this.dataSourceDisplay.getLatLngBounds(target);
Expand All @@ -516,19 +514,25 @@ export default class Leaflet extends GlobeOrMap {
extent = target.cesiumRectangle;
}
if (!isDefined(extent)) {
// Zoom to the first item!
return this.doZoomTo(target.mapItems[0], flightDurationSeconds);
}
} else {
extent = target.rectangle;
}

// Account for a bounding box crossing the date line.
if (extent.east < extent.west) {
extent = Rectangle.clone(extent);
extent.east += CesiumMath.TWO_PI;
// Ensure extent is defined before accessing its properties
if (isDefined(extent)) {
// Account for a bounding box crossing the date line.
if (extent.east < extent.west) {
extent = Rectangle.clone(extent);
extent.east += CesiumMath.TWO_PI;
}
bounds = rectangleToLatLngBounds(extent);
} else {
// Handle the case where extent is undefined
console.error("Unable to determine bounds for zooming.");
return Promise.resolve();
}
bounds = rectangleToLatLngBounds(extent);
}

if (isDefined(bounds)) {
Expand Down
2 changes: 1 addition & 1 deletion lib/Models/Terria.ts
Original file line number Diff line number Diff line change
Expand Up @@ -815,7 +815,7 @@ export default class Terria {
}

@computed get modelValues() {
return Array.from(this.models.values());
return Array.from<BaseModel>(this.models.values());
}

@computed
Expand Down
2 changes: 1 addition & 1 deletion lib/ReactViews/ExplorerWindow/Tabs/MyDataTab/AddData.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ class AddData extends React.Component {
this.props.terria,
this.props.viewState,
this.state.remoteUrl,
this.props.viewState.remoteDataType.value
this.props.viewState.remoteDataType?.value
);
} else if (this.props.viewState.remoteDataType.value === "json") {
promise = loadJson(this.state.remoteUrl)
Expand Down
1 change: 1 addition & 0 deletions lib/ThirdParty/proj4-fully-loaded/index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
declare module "proj4-fully-loaded";
Loading

0 comments on commit dc3ec3c

Please sign in to comment.