terriajs
Version:
Geospatial data visualization platform.
900 lines (800 loc) • 27.8 kB
text/typescript
import {
action,
computed,
IReactionDisposer,
observable,
reaction,
runInAction,
makeObservable
} from "mobx";
import { ReactNode, MouseEvent, ComponentType, Ref } from "react";
import defined from "terriajs-cesium/Source/Core/defined";
import addedByUser from "../Core/addedByUser";
import {
Category,
HelpAction,
StoryAction
} from "../Core/AnalyticEvents/analyticEvents";
import Result from "../Core/Result";
import triggerResize from "../Core/triggerResize";
import PickedFeatures from "../Map/PickedFeatures/PickedFeatures";
import CatalogMemberMixin, { getName } from "../ModelMixins/CatalogMemberMixin";
import GroupMixin from "../ModelMixins/GroupMixin";
import MappableMixin from "../ModelMixins/MappableMixin";
import ReferenceMixin from "../ModelMixins/ReferenceMixin";
import CommonStrata from "../Models/Definition/CommonStrata";
import { BaseModel } from "../Models/Definition/Model";
import getAncestors from "../Models/getAncestors";
import { SelectableDimension } from "../Models/SelectableDimensions/SelectableDimensions";
import Terria from "../Models/Terria";
import { ViewingControl } from "../Models/ViewingControls";
import { SATELLITE_HELP_PROMPT_KEY } from "../ReactViews/HelpScreens/SatelliteHelpPrompt";
import { animationDuration } from "../ReactViews/StandardUserInterface/StandardUserInterface";
import { FeatureInfoPanelButtonGenerator } from "../ViewModels/FeatureInfoPanel";
import {
defaultTourPoints,
RelativePosition,
TourPoint
} from "./defaultTourPoints";
import SearchState from "./SearchState";
import CatalogSearchProviderMixin from "../ModelMixins/SearchProviders/CatalogSearchProviderMixin";
import { getMarkerCatalogItem } from "../Models/LocationMarkerUtils";
import CzmlCatalogItem from "../Models/Catalog/CatalogItems/CzmlCatalogItem";
export const DATA_CATALOG_NAME = "data-catalog";
export const USER_DATA_NAME = "my-data";
// check showWorkbenchButton delay and transforms
// export const WORKBENCH_RESIZE_ANIMATION_DURATION = 250;
export const WORKBENCH_RESIZE_ANIMATION_DURATION = 500;
interface ViewStateOptions {
terria: Terria;
catalogSearchProvider: CatalogSearchProviderMixin.Instance | undefined;
errorHandlingProvider?: any;
}
/**
* Root of a global view model. Presumably this should get nested as more stuff goes into it. Basically this belongs to
* the root of the UI and then it can choose to pass either the whole thing or parts down as props to its children.
*/
export default class ViewState {
readonly mobileViewOptions = Object.freeze({
data: "data",
preview: "preview",
nowViewing: "nowViewing",
locationSearchResults: "locationSearchResults"
});
readonly searchState: SearchState;
readonly terria: Terria;
readonly relativePosition = RelativePosition;
private _previewedItem: BaseModel | undefined;
get previewedItem() {
return this._previewedItem;
}
userDataPreviewedItem: BaseModel | undefined;
explorerPanelIsVisible: boolean = false;
activeTabCategory: string = DATA_CATALOG_NAME;
activeTabIdInCategory: string | undefined = undefined;
isDraggingDroppingFile: boolean = false;
mobileView: string | null = null;
isMapFullScreen: boolean = false;
myDataIsUploadView: boolean = true;
mobileMenuVisible: boolean = false;
explorerPanelAnimating: boolean = false;
topElement: string = "FeatureInfo";
// Map for storing react portal containers created by <Portal> component.
portals: Map<string, HTMLElement | null> = new Map();
storyBuilderShown: boolean = false;
// Flesh out later
showHelpMenu: boolean = false;
showSatelliteGuidance: boolean = false;
showWelcomeMessage: boolean = false;
selectedHelpMenuItem: string = "";
helpPanelExpanded: boolean = false;
disclaimerSettings: any | undefined = undefined;
disclaimerVisible: boolean = false;
videoGuideVisible: string = "";
trainerBarVisible: boolean = false;
trainerBarExpanded: boolean = false;
trainerBarShowingAllSteps: boolean = false;
selectedTrainerItem: string = "";
currentTrainerItemIndex: number = 0;
currentTrainerStepIndex: number = 0;
printWindow: Window | null = null;
/**
* The currently-selected web service type on the My Data -> Add web data panel.
*/
remoteDataType: any | undefined = undefined;
/**
* The ID of the Cesium ion token that is currently selected on the
* My Data -> Add web data -> Cesium ion panel.
*/
currentCesiumIonToken: string | undefined = undefined;
/**
* Toggles ActionBar visibility. Do not set manually, it is
* automatically set when rendering <ActionBar>
*/
isActionBarVisible = false;
/**
* A global list of functions that generate a {@link ViewingControl} option
* for the given catalog item instance. This is useful for plugins to extend
* the viewing control menu across catalog items.
*
* Use {@link ViewingControlsMenu.addMenuItem} instead of updating directly.
*/
readonly globalViewingControlOptions: ((
item: CatalogMemberMixin.Instance
) => ViewingControl | undefined)[] = [];
/**
* A global list of hooks for generating input controls for items in the workbench.
* The hooks in this list gets called once for each item in shown in the workbench.
* This is a mechanism for plugins to extend workbench input controls by adding new ones.
*
* Use {@link WorkbenchItem.Inputs.addInput} instead of updating directly.
*/
readonly workbenchItemInputGenerators: ((
item: BaseModel
) => SelectableDimension | undefined)[] = [];
/**
* A global list of generator functions for showing buttons in feature info panel.
* Use {@link FeatureInfoPanelButton.addButton} instead of updating directly.
*/
readonly featureInfoPanelButtonGenerators: FeatureInfoPanelButtonGenerator[] =
[];
setSelectedTrainerItem(trainerItem: string): void {
this.selectedTrainerItem = trainerItem;
}
setTrainerBarVisible(bool: boolean): void {
this.trainerBarVisible = bool;
}
setTrainerBarShowingAllSteps(bool: boolean): void {
this.trainerBarShowingAllSteps = bool;
}
setTrainerBarExpanded(bool: boolean): void {
this.trainerBarExpanded = bool;
// if collapsing trainer bar, also hide steps
if (!bool) {
this.trainerBarShowingAllSteps = bool;
}
}
setCurrentTrainerItemIndex(index: number): void {
this.currentTrainerItemIndex = index;
this.currentTrainerStepIndex = 0;
}
setCurrentTrainerStepIndex(index: number): void {
this.currentTrainerStepIndex = index;
}
setActionBarVisible(visible: boolean): void {
this.isActionBarVisible = visible;
}
/**
* Bottom dock state & action
*/
bottomDockHeight: number = 0;
setBottomDockHeight(height: number): void {
if (this.bottomDockHeight !== height) {
this.bottomDockHeight = height;
}
}
errorProvider: any | null = null;
// default value is null, because user has not made decision to show or
// not show story
// will be explicitly set to false when user 1. dismiss story
// notification or 2. close a story
storyShown: boolean | null = null;
currentStoryId: number = 0;
featurePrompts: any[] = [];
/**
* we need a layering system for touring the app, but also a way for it to be
* chopped and changed from a terriamap
*
* this will be slightly different to the help sequences that were done in
* the past, but may evolve to become a "sequence" (where the UI gets
* programatically toggled to delve deeper into the app, e.g. show the user
* how to add data via the data catalog window)
*
* rough points
* - "all guide points visible"
* -
*
* draft structure(?):
*
* maybe each "guide" item will have
* {
* ref: (react ref object)
* dotOffset: (which way the dot and guide should be positioned relative to the ref component)
* content: (component, more flexibility than a string)
* ...?
* }
* and guide props?
* {
* enabled: parent component to decide this based on active index
* ...?
* }
* */
tourPoints: TourPoint[] = defaultTourPoints;
showTour: boolean = false;
appRefs: Map<string, Ref<HTMLElement>> = new Map();
currentTourIndex: number = -1;
showCollapsedNavigation: boolean = false;
get tourPointsWithValidRefs() {
// should viewstate.ts reach into document? seems unavoidable if we want
// this to be the true source of tourPoints.
// update: well it turns out you can be smarter about it and actually
// properly clean up your refs - so we'll leave that up to the UI to
// provide valid refs
return this.tourPoints
.slice()
.sort((a, b) => {
return a.priority - b.priority;
})
.filter(
(tourPoint) => (this.appRefs as any).get(tourPoint.appRefName)?.current
);
}
setTourIndex(index: number): void {
this.currentTourIndex = index;
}
setShowTour(bool: boolean): void {
// If we're enabling the tour, make sure the trainer is collapsed
if (bool) {
this.setTrainerBarExpanded(false);
// Ensure workbench is shown
this.setIsMapFullScreen(false);
setTimeout(() => {
runInAction(() => {
this.showTour = bool;
});
}, animationDuration || 1);
} else {
this.showTour = bool;
}
}
closeTour(): void {
this.currentTourIndex = -1;
this.showTour = false;
}
previousTourPoint(): void {
const currentIndex = this.currentTourIndex;
if (currentIndex !== 0) {
this.currentTourIndex = currentIndex - 1;
}
}
nextTourPoint(): void {
const totalTourPoints = this.tourPointsWithValidRefs.length;
const currentIndex = this.currentTourIndex;
if (currentIndex >= totalTourPoints - 1) {
this.closeTour();
} else {
this.currentTourIndex = currentIndex + 1;
}
}
closeCollapsedNavigation(): void {
this.showCollapsedNavigation = false;
}
updateAppRef(refName: string, ref: Ref<HTMLElement>): void {
if (!this.appRefs.get(refName) || this.appRefs.get(refName) !== ref) {
this.appRefs.set(refName, ref);
}
}
deleteAppRef(refName: string): void {
this.appRefs.delete(refName);
}
/**
* Gets or sets a value indicating whether the small screen (mobile) user interface should be used.
* @type {Boolean}
*/
useSmallScreenInterface: boolean = false;
/**
* Gets or sets a value indicating whether the feature info panel is visible.
* @type {Boolean}
*/
featureInfoPanelIsVisible: boolean = false;
/**
* Gets or sets a value indicating whether the feature info panel is collapsed.
* When it's collapsed, only the title bar is visible.
* @type {Boolean}
*/
featureInfoPanelIsCollapsed: boolean = false;
/**
* True if this is (or will be) the first time the user has added data to the map.
* @type {Boolean}
*/
firstTimeAddingData: boolean = true;
/**
* Gets or sets a value indicating whether the feedback form is visible.
* @type {Boolean}
*/
feedbackFormIsVisible: boolean = false;
/**
* Gets or sets a value indicating whether the catalog's modal share panel
* is currently visible.
*/
shareModalIsVisible: boolean = false; // Small share modal inside StoryEditor
/**
* Used to indicate that the Share Panel should stay open even if it loses focus.
* This is used when clicking a help link in the Share Panel - The Help Panel will open, and when it is closed, the Share Panel should still be visible for the user to continue their task.
*/
retainSharePanel: boolean = false; // The large share panel accessed via Share/Print button
settingsPanelIsVisible: boolean = false;
/**
* The currently open tool
*/
currentTool?: Tool;
panel: ReactNode;
private _pickedFeaturesSubscription: IReactionDisposer;
private _disclaimerVisibleSubscription: IReactionDisposer;
private _isMapFullScreenSubscription: IReactionDisposer;
private _showStoriesSubscription: IReactionDisposer;
private _mobileMenuSubscription: IReactionDisposer;
private _storyPromptSubscription: IReactionDisposer;
private _previewedItemIdSubscription: IReactionDisposer;
private _locationMarkerSubscription: IReactionDisposer;
private _workbenchHasTimeWMSSubscription: IReactionDisposer;
private _storyBeforeUnloadSubscription: IReactionDisposer;
constructor(options: ViewStateOptions) {
makeObservable(this);
const terria = options.terria;
this.searchState = new SearchState({
terria,
catalogSearchProvider: options.catalogSearchProvider
});
this.errorProvider = options.errorHandlingProvider
? options.errorHandlingProvider
: null;
this.terria = terria;
// When features are picked, show the feature info panel.
this._pickedFeaturesSubscription = reaction(
() => this.terria.pickedFeatures,
(pickedFeatures: PickedFeatures | undefined) => {
if (defined(pickedFeatures)) {
this.featureInfoPanelIsVisible = true;
this.featureInfoPanelIsCollapsed = false;
} else {
this.featureInfoPanelIsVisible = false;
}
}
);
// When disclaimer is shown, ensure fullscreen
// unsure about this behaviour because it nudges the user off center
// of the original camera set from config once they acknowdge
this._disclaimerVisibleSubscription = reaction(
() => this.disclaimerVisible,
(disclaimerVisible) => {
this.isMapFullScreen =
disclaimerVisible ||
terria.userProperties.get("hideWorkbench") === "1" ||
terria.userProperties.get("hideExplorerPanel") === "1";
}
);
this._isMapFullScreenSubscription = reaction(
() =>
terria.userProperties.get("hideWorkbench") === "1" ||
terria.userProperties.get("hideExplorerPanel") === "1",
(isMapFullScreen: boolean) => {
this.isMapFullScreen = isMapFullScreen;
// if /#hideWorkbench=1 exists in url onload, show stories directly
// any show/hide workbench will not automatically show story
if (!defined(this.storyShown)) {
// why only check config params here? because terria.stories are not
// set at the moment, and that property will be checked in rendering
// Here are all are checking are: is terria story enabled in this app?
// if so we should show it when app first load, if workbench is hidden
this.storyShown = terria.configParameters.storyEnabled;
}
}
);
this._showStoriesSubscription = reaction(
() => Boolean(terria.userProperties.get("playStory")),
(playStory: boolean) => {
this.storyShown = terria.configParameters.storyEnabled && playStory;
}
);
this._mobileMenuSubscription = reaction(
() => this.mobileMenuVisible,
(mobileMenuVisible: boolean) => {
if (mobileMenuVisible) {
this.explorerPanelIsVisible = false;
this.switchMobileView(null);
}
}
);
this._workbenchHasTimeWMSSubscription = reaction(
() => this.terria.workbench.hasTimeWMS,
(hasTimeWMS: boolean) => {
if (
this.terria.configParameters.showInAppGuides &&
hasTimeWMS === true &&
// // only show it once
!this.terria.getLocalProperty(`${SATELLITE_HELP_PROMPT_KEY}Prompted`)
) {
this.setShowSatelliteGuidance(true);
this.toggleFeaturePrompt(SATELLITE_HELP_PROMPT_KEY, true, true);
}
}
);
this._storyPromptSubscription = reaction(
() => this.storyShown,
(storyShown: boolean | null) => {
if (storyShown === false) {
// only show it once
if (!this.terria.getLocalProperty("storyPrompted")) {
this.toggleFeaturePrompt("story", true, false);
}
}
}
);
this._locationMarkerSubscription = reaction(
() => getMarkerCatalogItem(this.terria),
(item: CzmlCatalogItem | undefined) => {
if (item) {
terria.overlays.add(item);
/* dispose subscription after init */
this._locationMarkerSubscription();
}
}
);
this._previewedItemIdSubscription = reaction(
() => this.terria.previewedItemId,
async (previewedItemId: string | undefined) => {
if (previewedItemId === undefined) {
return;
}
try {
const result =
await this.terria.getModelByIdShareKeyOrCatalogIndex(
previewedItemId
);
result.throwIfError();
const model = result.throwIfUndefined();
this.viewCatalogMember(model);
} catch (e) {
terria.raiseErrorToUser(e, {
message: `Couldn't find model \`${previewedItemId}\` for preview`
});
}
}
);
const handleWindowClose = (e: BeforeUnloadEvent) => {
// Cancel the event
e.preventDefault(); // If you prevent default behavior in Mozilla Firefox prompt will always be shown
// Chrome requires returnValue to be set
e.returnValue = "";
};
this._storyBeforeUnloadSubscription = reaction(
() => this.terria.stories.length > 0,
(hasScenes) => {
if (hasScenes) {
window.addEventListener("beforeunload", handleWindowClose);
} else {
window.removeEventListener("beforeunload", handleWindowClose);
}
}
);
}
dispose(): void {
this._pickedFeaturesSubscription();
this._disclaimerVisibleSubscription();
this._mobileMenuSubscription();
this._isMapFullScreenSubscription();
this._showStoriesSubscription();
this._storyPromptSubscription();
this._previewedItemIdSubscription();
this._workbenchHasTimeWMSSubscription();
this._locationMarkerSubscription();
this._storyBeforeUnloadSubscription();
this.searchState.dispose();
}
triggerResizeEvent(): void {
triggerResize();
}
setIsMapFullScreen(
bool: boolean,
animationDuration = WORKBENCH_RESIZE_ANIMATION_DURATION
): void {
this.isMapFullScreen = bool;
// Allow any animations to finish, then trigger a resize.
// (wing): much better to do by listening for transitionend, but will leave
// this as is until that's in place
setTimeout(function () {
// should we do this here in viewstate? it pulls in browser dependent things,
// and (defensively) calls it.
// but only way to ensure we trigger this resize, by standardising fullscreen
// toggle through an action.
triggerResize();
}, animationDuration);
}
toggleStoryBuilder(): void {
this.storyBuilderShown = !this.storyBuilderShown;
}
setTopElement(key: string): void {
this.topElement = key;
}
openAddData(): void {
this.explorerPanelIsVisible = true;
this.activeTabCategory = DATA_CATALOG_NAME;
this.switchMobileView(this.mobileViewOptions.data);
}
openUserData(): void {
this.explorerPanelIsVisible = true;
this.activeTabCategory = USER_DATA_NAME;
}
closeCatalog(): void {
this.explorerPanelIsVisible = false;
this.switchMobileView(null);
this.clearPreviewedItem();
}
searchInCatalog(query: string): void {
this.openAddData();
this.searchState.catalogSearchText = query;
this.searchState.searchCatalog();
}
/**
* Open settings panel
*/
openSettingsPanel(): void {
this.settingsPanelIsVisible = true;
}
clearPreviewedItem(): void {
this.userDataPreviewedItem = undefined;
this._previewedItem = undefined;
}
/**
* Views a model in the catalog. If model is a
*
* - `Reference` - it will be dereferenced first.
* - `CatalogMember` - `loadMetadata` will be called
* - `Group` - its `isOpen` trait will be set according to the value of the `isOpen` parameter in the `stratum` indicated.
* - If after doing this the group is open, its members will be loaded with a call to `loadMembers`.
* - `Mappable` - `loadMapItems` will be called
*
* Then (if no errors have occurred) it will open the catalog.
* Note - `previewItem` is set at the start of the function, regardless of errors.
*
* @param item The model to view in catalog.
* @param [isOpen=true] True if the group should be opened. False if it should be closed.
* @param stratum The stratum in which to mark the group opened or closed.
* @param openAddData True if data catalog window should be opened.
*/
async viewCatalogMember(
item: BaseModel,
isOpen: boolean = true,
stratum: string = CommonStrata.user,
openAddData = true
): Promise<Result<void>> {
// Set preview item before loading - so we can see loading indicator and errors in DataPreview panel.
runInAction(() => (this._previewedItem = item));
try {
// If item is a Reference - recursively load and call viewCatalogMember on the target
if (ReferenceMixin.isMixedInto(item)) {
(await item.loadReference()).throwIfError();
if (item.target) {
return this.viewCatalogMember(item.target);
} else {
return Result.error(`Could not view catalog member ${getName(item)}`);
}
}
// Open "Add Data"
if (openAddData) {
if (addedByUser(item)) {
runInAction(() => (this.userDataPreviewedItem = item));
this.openUserData();
} else {
runInAction(() => {
this.openAddData();
if (this.terria.configParameters.tabbedCatalog) {
const parentGroups = getAncestors(item);
if (parentGroups.length > 0) {
// Go to specific tab
this.activeTabIdInCategory = parentGroups[0].uniqueId;
}
}
});
}
// mobile switch to now viewing if not viewing a group
if (!GroupMixin.isMixedInto(item)) {
this.switchMobileView(this.mobileViewOptions.preview);
}
}
if (GroupMixin.isMixedInto(item)) {
item.setTrait(stratum, "isOpen", isOpen);
if (item.isOpen) {
(await item.loadMembers()).throwIfError();
}
} else if (MappableMixin.isMixedInto(item))
(await item.loadMapItems()).throwIfError();
else if (CatalogMemberMixin.isMixedInto(item))
(await item.loadMetadata()).throwIfError();
} catch (e) {
return Result.error(e, `Could not view catalog member ${getName(item)}`);
}
return Result.none();
}
switchMobileView(viewName: string | null): void {
this.mobileView = viewName;
}
showHelpPanel(): void {
this.terria.analytics?.logEvent(Category.help, HelpAction.panelOpened);
this.showHelpMenu = true;
this.helpPanelExpanded = false;
this.selectedHelpMenuItem = "";
this.setTopElement("HelpPanel");
}
openHelpPanelItemFromSharePanel(
evt: MouseEvent<HTMLDivElement>,
itemName: string
): void {
evt.preventDefault();
evt.stopPropagation();
this.setRetainSharePanel(true);
this.showHelpPanel();
this.selectHelpMenuItem(itemName);
}
selectHelpMenuItem(key: string): void {
this.selectedHelpMenuItem = key;
this.helpPanelExpanded = true;
}
hideHelpPanel(): void {
this.showHelpMenu = false;
}
setRetainSharePanel(retain: boolean): void {
this.retainSharePanel = retain;
}
changeSearchState(newText: string): void {
this.searchState.catalogSearchText = newText;
}
setDisclaimerVisible(bool: boolean): void {
this.disclaimerVisible = bool;
}
hideDisclaimer(): void {
this.setDisclaimerVisible(false);
}
setShowSatelliteGuidance(showSatelliteGuidance: boolean): void {
this.showSatelliteGuidance = showSatelliteGuidance;
}
setShowWelcomeMessage(welcomeMessageShown: boolean): void {
this.showWelcomeMessage = welcomeMessageShown;
}
setVideoGuideVisible(videoName: string): void {
this.videoGuideVisible = videoName;
}
/**
* Removes references of a model from viewState
*/
removeModelReferences(model: BaseModel): void {
if (this._previewedItem === model) this._previewedItem = undefined;
if (this.userDataPreviewedItem === model)
this.userDataPreviewedItem = undefined;
}
toggleFeaturePrompt(
feature: string,
state: boolean,
persistent: boolean = false
): void {
const featureIndexInPrompts = this.featurePrompts.indexOf(feature);
if (
state &&
featureIndexInPrompts < 0 &&
!this.terria.getLocalProperty(`${feature}Prompted`)
) {
this.featurePrompts.push(feature);
} else if (!state && featureIndexInPrompts >= 0) {
this.featurePrompts.splice(featureIndexInPrompts, 1);
}
if (persistent) {
this.terria.setLocalProperty(`${feature}Prompted`, true);
}
}
viewingUserData(): boolean {
return this.activeTabCategory === USER_DATA_NAME;
}
afterTerriaStarted(): void {
if (this.terria.configParameters.openAddData) {
this.openAddData();
}
}
openTool(tool: Tool): void {
this.currentTool = tool;
}
closeTool(): void {
this.currentTool = undefined;
}
setPrintWindow(window: Window | null): void {
if (this.printWindow) {
this.printWindow.close();
}
this.printWindow = window;
}
toggleMobileMenu(): void {
this.setTopElement("mobileMenu");
this.mobileMenuVisible = !this.mobileMenuVisible;
}
runStories(): void {
this.storyBuilderShown = false;
this.storyShown = true;
setTimeout(function () {
triggerResize();
}, animationDuration || 1);
this.terria.currentViewer.notifyRepaintRequired();
this.terria.analytics?.logEvent(Category.story, StoryAction.runStory);
}
get breadcrumbsShown() {
return (
this.previewedItem !== undefined ||
this.userDataPreviewedItem !== undefined
);
}
get isToolOpen() {
return this.currentTool !== undefined;
}
get hideMapUi() {
return (
this.terria.notificationState.currentNotification !== undefined &&
this.terria.notificationState.currentNotification!.hideUi
);
}
get isMapZooming() {
return this.terria.currentViewer.isMapZooming;
}
/**
* Returns true if the user is currently interacting with the map - like
* picking a point or drawing a shape.
*/
get isMapInteractionActive() {
return this.terria.mapInteractionModeStack.length > 0;
}
}
interface Tool {
toolName: string;
getToolComponent: () => ComponentType<any> | Promise<ComponentType<any>>;
params?: any;
}