|
9 | 9 | buildSelectorTimeBinding, |
10 | 10 | registerTemporalLayer, |
11 | 11 | unregisterTemporalLayer, |
| 12 | + isTimeSliderIdle, |
12 | 13 | TIME_SLIDER_PLUGIN_ID, |
13 | 14 | type TemporalLayerAdapter, |
14 | 15 | setZarrLayerSelector, |
@@ -106,6 +107,7 @@ import { pickZarrDirectory, zarrDirectoryPickerSupported } from "../lib/zarr-dir |
106 | 107 | import { openExternalLink } from "../lib/open-external"; |
107 | 108 | import { fetchUrlBytes } from "../lib/native-http"; |
108 | 109 | import { partitionProjectPluginManifestUrls } from "../lib/plugin-trust"; |
| 110 | +import { setTimeSliderOpenedByBinding, shouldCloseTimeSliderDock } from "../lib/time-slider-dock"; |
109 | 111 | import { createWmsTileUrl, normalizeWmsVersion } from "../components/layout/add-data/helpers"; |
110 | 112 | import { createExternalNativeStoreLayer } from "../lib/external-native-layer"; |
111 | 113 | import { mergeStringLists } from "../lib/string-lists"; |
@@ -239,6 +241,17 @@ if (zarrDirectoryPickerSupported()) { |
239 | 241 | setZarrLocalStoreProvider(pickZarrDirectory); |
240 | 242 | } |
241 | 243 |
|
| 244 | +// Forget that a binding opened the Time Slider dock as soon as the plugin goes |
| 245 | +// inactive by any route (#1512), so a later manual activation is not mistaken |
| 246 | +// for a binding-opened one and closed out from under the user. |
| 247 | +let timeSliderWasActive = false; |
| 248 | +manager.subscribe(() => { |
| 249 | + const active = manager.isActive(TIME_SLIDER_PLUGIN_ID); |
| 250 | + if (active === timeSliderWasActive) return; |
| 251 | + timeSliderWasActive = active; |
| 252 | + if (!active) setTimeSliderOpenedByBinding(false); |
| 253 | +}); |
| 254 | + |
242 | 255 | let externalPluginsLoaded = false; |
243 | 256 | let externalPluginsLoadPromise: Promise<void> | null = null; |
244 | 257 | let externalPluginsLoadKey: string | null = null; |
@@ -704,12 +717,85 @@ export function bindTemporalLayer( |
704 | 717 | // dialog does when it commits). |
705 | 718 | timeFilter: undefined, |
706 | 719 | }); |
707 | | - if (!manager.isActive(TIME_SLIDER_PLUGIN_ID)) { |
708 | | - manager.toggle(TIME_SLIDER_PLUGIN_ID, createAppAPI(mapControllerRef)); |
709 | | - } |
| 720 | + activateTimeSliderForBinding(mapControllerRef); |
710 | 721 | return true; |
711 | 722 | } |
712 | 723 |
|
| 724 | +/** |
| 725 | + * Open the Time Slider dock because a layer was just bound to it, and remember |
| 726 | + * that the binding is what opened it so {@link useTimeSliderAutoClose} may close |
| 727 | + * it again when the last binding goes away (#1512). |
| 728 | + * |
| 729 | + * A no-op when the dock is already showing — including when the user opened it |
| 730 | + * themselves, which deliberately leaves the "opened by a binding" flag false so |
| 731 | + * their dock is never taken away underneath them. |
| 732 | + * |
| 733 | + * Call this **after** the binding has been written to the layer's metadata, so |
| 734 | + * the dock adopts it on activation. |
| 735 | + * |
| 736 | + * @param mapControllerRef - Used to build the app API for activation. |
| 737 | + */ |
| 738 | +export function activateTimeSliderForBinding( |
| 739 | + mapControllerRef?: RefObject<MapController | null>, |
| 740 | +): void { |
| 741 | + if (manager.isActive(TIME_SLIDER_PLUGIN_ID)) return; |
| 742 | + const before = JSON.stringify(projectPluginStateSnapshot()); |
| 743 | + try { |
| 744 | + manager.activate(TIME_SLIDER_PLUGIN_ID, createAppAPI(mapControllerRef)); |
| 745 | + } catch (error) { |
| 746 | + // Plugin controls are imperative MapLibre code, so a throw here would escape |
| 747 | + // React's error boundaries. Contain it, exactly as usePluginRegistry.toggle |
| 748 | + // does, and leave the project state unwritten. |
| 749 | + reportPluginError(TIME_SLIDER_PLUGIN_ID, "toggle", error); |
| 750 | + return; |
| 751 | + } |
| 752 | + setTimeSliderOpenedByBinding(manager.isActive(TIME_SLIDER_PLUGIN_ID)); |
| 753 | + persistProjectPluginState(before); |
| 754 | +} |
| 755 | + |
| 756 | +/** |
| 757 | + * Close a binding-opened Time Slider once it has nothing left to drive (#1512). |
| 758 | + * |
| 759 | + * `activatePlugin` / `registerTemporalLayer({ bind: true })` open the dock when |
| 760 | + * the first temporal layer appears, but nothing closed it again when the last |
| 761 | + * one went away by a route other than the Layers panel's explicit "Unbind" |
| 762 | + * action — removing the bound layer, or a plugin swapping a temporal dataset for |
| 763 | + * a single-period one. The dock then lingered over the map, implying a timeline |
| 764 | + * no layer has. |
| 765 | + * |
| 766 | + * Only a dock opened *by* a binding is closed; one the user opened from the |
| 767 | + * Plugins menu stays put. `isTimeSliderIdle` additionally keeps it open while |
| 768 | + * the dock's own raster sources or a KML `<TimeSpan>` overlay remain, since the |
| 769 | + * dock is the only way to reach those. |
| 770 | + * |
| 771 | + * Mounted once near the app root so it covers every way a binding can |
| 772 | + * disappear, not just the Layers panel. |
| 773 | + */ |
| 774 | +export function useTimeSliderAutoClose(mapControllerRef: RefObject<MapController | null>): void { |
| 775 | + useEffect(() => { |
| 776 | + // Subscribed rather than selected from the store so no component re-renders |
| 777 | + // on every layer edit just to run this check. |
| 778 | + const check = () => { |
| 779 | + if (!shouldCloseTimeSliderDock(manager.isActive(TIME_SLIDER_PLUGIN_ID), isTimeSliderIdle)) { |
| 780 | + return; |
| 781 | + } |
| 782 | + const before = JSON.stringify(projectPluginStateSnapshot()); |
| 783 | + try { |
| 784 | + // Deactivating prunes the dock's own store layers, which re-enters this |
| 785 | + // subscription; those passes find the dock already idle-and-closing and |
| 786 | + // the plugin's own deactivate is a no-op once its control is gone. |
| 787 | + manager.deactivate(TIME_SLIDER_PLUGIN_ID, createAppAPI(mapControllerRef)); |
| 788 | + } catch (error) { |
| 789 | + reportPluginError(TIME_SLIDER_PLUGIN_ID, "toggle", error); |
| 790 | + return; |
| 791 | + } |
| 792 | + persistProjectPluginState(before); |
| 793 | + }; |
| 794 | + check(); |
| 795 | + return useAppStore.subscribe(check); |
| 796 | + }, [mapControllerRef]); |
| 797 | +} |
| 798 | + |
713 | 799 | export function createAppAPI(mapControllerRef?: RefObject<MapController | null>) { |
714 | 800 | const store = useAppStore.getState(); |
715 | 801 | // Captured so methods that delegate to plugin helpers taking the AppAPI |
@@ -874,6 +960,23 @@ export function createAppAPI(mapControllerRef?: RefObject<MapController | null>) |
874 | 960 | if (!activated || !manager.isActive(pluginId)) return false; |
875 | 961 | return state === undefined ? true : manager.applyPluginState(pluginId, api, state); |
876 | 962 | }, |
| 963 | + // The counterpart of activatePlugin, so a plugin that opened another |
| 964 | + // plugin's panel can close it again (#1512). Persisted like the toolbar's |
| 965 | + // own toggle, so the project records the panel as off; a throw from the |
| 966 | + // target's imperative teardown is contained rather than escaping into the |
| 967 | + // caller. |
| 968 | + deactivatePlugin: (pluginId: string) => { |
| 969 | + if (!manager.isActive(pluginId)) return false; |
| 970 | + const before = JSON.stringify(projectPluginStateSnapshot()); |
| 971 | + try { |
| 972 | + manager.deactivate(pluginId, api); |
| 973 | + } catch (error) { |
| 974 | + reportPluginError(pluginId, "deactivate", error); |
| 975 | + return false; |
| 976 | + } |
| 977 | + persistProjectPluginState(before); |
| 978 | + return !manager.isActive(pluginId); |
| 979 | + }, |
877 | 980 | queryOvertureFeatures, |
878 | 981 | addLayerGroup: (name?: string, layerIds?: string[]) => |
879 | 982 | useAppStore.getState().addLayerGroup(name, layerIds), |
|
0 commit comments