tangram-core 0.3.0__cp310-cp310-manylinux_2_28_aarch64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tangram_core/App.vue +441 -0
- tangram_core/CommandPalette.vue +200 -0
- tangram_core/HighlightText.vue +32 -0
- tangram_core/__Timeline.vue +300 -0
- tangram_core/__init__.py +5 -0
- tangram_core/__main__.py +331 -0
- tangram_core/_core.cpython-310-aarch64-linux-gnu.so +0 -0
- tangram_core/_core.pyi +38 -0
- tangram_core/api.ts +652 -0
- tangram_core/backend.py +458 -0
- tangram_core/components.ts +2 -0
- tangram_core/config.py +167 -0
- tangram_core/dist-frontend/aggregation-layers.js +521 -0
- tangram_core/dist-frontend/aggregation-layers.js.map +1 -0
- tangram_core/dist-frontend/assets/_commonjsHelpers-CqkleIqs.js +2 -0
- tangram_core/dist-frontend/assets/_commonjsHelpers-CqkleIqs.js.map +1 -0
- tangram_core/dist-frontend/assets/array-utils-flat-BBMak426.js +11 -0
- tangram_core/dist-frontend/assets/array-utils-flat-BBMak426.js.map +1 -0
- tangram_core/dist-frontend/assets/assert-cyW4mg7q.js +3 -0
- tangram_core/dist-frontend/assets/assert-cyW4mg7q.js.map +1 -0
- tangram_core/dist-frontend/assets/b612-latin-400-italic-DePNXA0a.woff +0 -0
- tangram_core/dist-frontend/assets/b612-latin-400-italic-a-4GLPtl.woff2 +0 -0
- tangram_core/dist-frontend/assets/b612-latin-400-normal-CC98FVm_.woff2 +0 -0
- tangram_core/dist-frontend/assets/b612-latin-400-normal-JbZ7xwUX.woff +0 -0
- tangram_core/dist-frontend/assets/b612-latin-700-normal-B_Snq1wd.woff +0 -0
- tangram_core/dist-frontend/assets/b612-latin-700-normal-BinQrnoB.woff2 +0 -0
- tangram_core/dist-frontend/assets/clip-extension-D-rbmFPj.js +26 -0
- tangram_core/dist-frontend/assets/clip-extension-D-rbmFPj.js.map +1 -0
- tangram_core/dist-frontend/assets/color-CUNNsFV-.js +17 -0
- tangram_core/dist-frontend/assets/color-CUNNsFV-.js.map +1 -0
- tangram_core/dist-frontend/assets/cube-geometry-v0HQ793i.js +2 -0
- tangram_core/dist-frontend/assets/cube-geometry-v0HQ793i.js.map +1 -0
- tangram_core/dist-frontend/assets/deep-equal-BTW2ZN6S.js +2 -0
- tangram_core/dist-frontend/assets/deep-equal-BTW2ZN6S.js.map +1 -0
- tangram_core/dist-frontend/assets/fly-to-interpolator-CIXGjOdo.js +2 -0
- tangram_core/dist-frontend/assets/fly-to-interpolator-CIXGjOdo.js.map +1 -0
- tangram_core/dist-frontend/assets/geojson-layer-DgMOQ4Qu.js +1010 -0
- tangram_core/dist-frontend/assets/geojson-layer-DgMOQ4Qu.js.map +1 -0
- tangram_core/dist-frontend/assets/globe-view-Day_n1iB.js +94 -0
- tangram_core/dist-frontend/assets/globe-view-Day_n1iB.js.map +1 -0
- tangram_core/dist-frontend/assets/globe-viewport-tqhQW7C4.js +2 -0
- tangram_core/dist-frontend/assets/globe-viewport-tqhQW7C4.js.map +1 -0
- tangram_core/dist-frontend/assets/image-loader-hHJsndO6.js +2 -0
- tangram_core/dist-frontend/assets/image-loader-hHJsndO6.js.map +1 -0
- tangram_core/dist-frontend/assets/inconsolata-latin-400-normal-DTZQ6lD6.woff2 +0 -0
- tangram_core/dist-frontend/assets/inconsolata-latin-400-normal-HYADljCo.woff +0 -0
- tangram_core/dist-frontend/assets/inconsolata-latin-700-normal-ByjKuJjN.woff2 +0 -0
- tangram_core/dist-frontend/assets/inconsolata-latin-700-normal-DzgUY3Rl.woff +0 -0
- tangram_core/dist-frontend/assets/inconsolata-latin-ext-400-normal-BaHVOdFB.woff2 +0 -0
- tangram_core/dist-frontend/assets/inconsolata-latin-ext-400-normal-yvPjCxxx.woff +0 -0
- tangram_core/dist-frontend/assets/inconsolata-latin-ext-700-normal-D0Kpgs_9.woff2 +0 -0
- tangram_core/dist-frontend/assets/inconsolata-latin-ext-700-normal-Dlt-daqV.woff +0 -0
- tangram_core/dist-frontend/assets/inconsolata-vietnamese-400-normal-ByiM2lek.woff +0 -0
- tangram_core/dist-frontend/assets/inconsolata-vietnamese-400-normal-DfC_iMic.woff2 +0 -0
- tangram_core/dist-frontend/assets/inconsolata-vietnamese-700-normal-DLCFFAUf.woff +0 -0
- tangram_core/dist-frontend/assets/inconsolata-vietnamese-700-normal-DuasYmn8.woff2 +0 -0
- tangram_core/dist-frontend/assets/index-CcogpxdD.js +824 -0
- tangram_core/dist-frontend/assets/index-CcogpxdD.js.map +1 -0
- tangram_core/dist-frontend/assets/index-SSLdizTv.css +1 -0
- tangram_core/dist-frontend/assets/layer-DPcO4AXQ.js +555 -0
- tangram_core/dist-frontend/assets/layer-DPcO4AXQ.js.map +1 -0
- tangram_core/dist-frontend/assets/layer-extension-CYwTXf73.js +2 -0
- tangram_core/dist-frontend/assets/layer-extension-CYwTXf73.js.map +1 -0
- tangram_core/dist-frontend/assets/mesh-layers-wiqredoy.js +1123 -0
- tangram_core/dist-frontend/assets/mesh-layers-wiqredoy.js.map +1 -0
- tangram_core/dist-frontend/assets/orthographic-viewport-B4nCj5tn.js +2 -0
- tangram_core/dist-frontend/assets/orthographic-viewport-B4nCj5tn.js.map +1 -0
- tangram_core/dist-frontend/assets/pick-layers-pass-C-3k0wbN.js +2 -0
- tangram_core/dist-frontend/assets/pick-layers-pass-C-3k0wbN.js.map +1 -0
- tangram_core/dist-frontend/assets/project-BTjD2Imj.js +760 -0
- tangram_core/dist-frontend/assets/project-BTjD2Imj.js.map +1 -0
- tangram_core/dist-frontend/assets/roboto-condensed-cyrillic-400-italic-4qS3_zkX.woff2 +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-cyrillic-400-italic-CDK-EZBY.woff +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-cyrillic-400-normal-Bgns473E.woff +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-cyrillic-400-normal-_T2aQlWs.woff2 +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-cyrillic-500-normal-CvEVpWxD.woff +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-cyrillic-500-normal-s4PklZE0.woff2 +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-cyrillic-700-normal-9RN-Z7cI.woff2 +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-cyrillic-700-normal-BGMkBBYx.woff +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-cyrillic-ext-400-italic-C7erd-g8.woff +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-cyrillic-ext-400-italic-DR5R5TWx.woff2 +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-cyrillic-ext-400-normal-DGo1Ayjq.woff2 +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-cyrillic-ext-400-normal-WtM1l1qc.woff +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-cyrillic-ext-500-normal-C8FNIdXm.woff2 +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-cyrillic-ext-500-normal-TLDmfi3Q.woff +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-cyrillic-ext-700-normal-CTXjXnze.woff2 +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-cyrillic-ext-700-normal-CWPRiRXS.woff +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-greek-400-italic-CR6qj4Z4.woff2 +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-greek-400-italic-DHRaIs10.woff +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-greek-400-normal-D5vBSIyg.woff2 +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-greek-400-normal-FabMgVmk.woff +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-greek-500-normal-BIN62cw9.woff +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-greek-500-normal-Hsn-wDIp.woff2 +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-greek-700-normal-89Up2Xly.woff +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-greek-700-normal-DWMOA2VK.woff2 +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-latin-400-italic-D_BR-3LG.woff2 +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-latin-400-italic-om57GXsO.woff +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-latin-400-normal-BICmKrXV.woff2 +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-latin-400-normal-D2e7XwB1.woff +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-latin-500-normal-3p2daRJW.woff2 +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-latin-500-normal-Dc9bsamC.woff +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-latin-700-normal-BOl6B_hI.woff +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-latin-700-normal-DRbp0YnP.woff2 +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-latin-ext-400-italic-BXrkWnoY.woff +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-latin-ext-400-italic-Bhem1d5z.woff2 +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-latin-ext-400-normal-DT8nEsYA.woff +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-latin-ext-400-normal-OHaX69iP.woff2 +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-latin-ext-500-normal-CcSTXKtO.woff2 +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-latin-ext-500-normal-JgPl2bDS.woff +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-latin-ext-700-normal-B004qtqu.woff2 +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-latin-ext-700-normal-O6H_RRvN.woff +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-vietnamese-400-italic-BwUYFJ2t.woff2 +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-vietnamese-400-italic-DV8QogUk.woff +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-vietnamese-400-normal-0o1laQ-g.woff2 +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-vietnamese-400-normal-CPsdS8_S.woff +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-vietnamese-500-normal-G9shSJ2z.woff +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-vietnamese-500-normal-TFWhjk13.woff2 +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-vietnamese-700-normal-BtNeb9D6.woff +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-vietnamese-700-normal-D35V1G0s.woff2 +0 -0
- tangram_core/dist-frontend/assets/shader-Cbdysp2j.js +843 -0
- tangram_core/dist-frontend/assets/shader-Cbdysp2j.js.map +1 -0
- tangram_core/dist-frontend/assets/solid-polygon-layer-DJFl_7Ca.js +392 -0
- tangram_core/dist-frontend/assets/solid-polygon-layer-DJFl_7Ca.js.map +1 -0
- tangram_core/dist-frontend/assets/tesselator-CENyUZ2p.js +2 -0
- tangram_core/dist-frontend/assets/tesselator-CENyUZ2p.js.map +1 -0
- tangram_core/dist-frontend/assets/webgl-developer-tools-utTNOsNf.js +7 -0
- tangram_core/dist-frontend/assets/webgl-developer-tools-utTNOsNf.js.map +1 -0
- tangram_core/dist-frontend/assets/webgl-device-BYRB-GQX.js +3 -0
- tangram_core/dist-frontend/assets/webgl-device-BYRB-GQX.js.map +1 -0
- tangram_core/dist-frontend/assets/widget-BjgEeHAL.js +2 -0
- tangram_core/dist-frontend/assets/widget-BjgEeHAL.js.map +1 -0
- tangram_core/dist-frontend/core.js +60 -0
- tangram_core/dist-frontend/core.js.map +1 -0
- tangram_core/dist-frontend/extensions.js +609 -0
- tangram_core/dist-frontend/extensions.js.map +1 -0
- tangram_core/dist-frontend/favicon.ico +0 -0
- tangram_core/dist-frontend/favicon.png +0 -0
- tangram_core/dist-frontend/geo-layers.js +115 -0
- tangram_core/dist-frontend/geo-layers.js.map +1 -0
- tangram_core/dist-frontend/index.html +39 -0
- tangram_core/dist-frontend/json.js +3 -0
- tangram_core/dist-frontend/json.js.map +1 -0
- tangram_core/dist-frontend/layers.js +268 -0
- tangram_core/dist-frontend/layers.js.map +1 -0
- tangram_core/dist-frontend/mapbox.js +2 -0
- tangram_core/dist-frontend/mapbox.js.map +1 -0
- tangram_core/dist-frontend/mesh-layers.js +2 -0
- tangram_core/dist-frontend/mesh-layers.js.map +1 -0
- tangram_core/dist-frontend/widgets.js +3 -0
- tangram_core/dist-frontend/widgets.js.map +1 -0
- tangram_core/main.ts +28 -0
- tangram_core/package.json +62 -0
- tangram_core/plugin.py +109 -0
- tangram_core/plugin.ts +47 -0
- tangram_core/redis.py +89 -0
- tangram_core/user.css +114 -0
- tangram_core/utils.ts +143 -0
- tangram_core/vite-plugin-tangram.mjs +155 -0
- tangram_core-0.3.0.dist-info/METADATA +101 -0
- tangram_core-0.3.0.dist-info/RECORD +162 -0
- tangram_core-0.3.0.dist-info/WHEEL +4 -0
- tangram_core-0.3.0.dist-info/entry_points.txt +2 -0
tangram_core/plugin.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import functools
|
|
5
|
+
import importlib.metadata
|
|
6
|
+
import logging
|
|
7
|
+
import traceback
|
|
8
|
+
from collections.abc import Callable, Coroutine
|
|
9
|
+
from contextlib import AbstractAsyncContextManager
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import TYPE_CHECKING, Any, TypeAlias
|
|
12
|
+
|
|
13
|
+
from fastapi import APIRouter
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from .backend import BackendState
|
|
17
|
+
|
|
18
|
+
ServiceAsyncFunc: TypeAlias = Callable[[BackendState], Coroutine[Any, Any, None]]
|
|
19
|
+
ServiceFunc: TypeAlias = ServiceAsyncFunc | Callable[[BackendState], None]
|
|
20
|
+
Priority: TypeAlias = int
|
|
21
|
+
IntoFrontendConfigFunction: TypeAlias = Callable[[dict[str, Any]], Any]
|
|
22
|
+
Lifespan: TypeAlias = Callable[
|
|
23
|
+
[BackendState], AbstractAsyncContextManager[None, bool | None]
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Plugin:
|
|
31
|
+
"""Stores the metadata and registered API routes, background services and
|
|
32
|
+
frontend assets for a tangram plugin.
|
|
33
|
+
|
|
34
|
+
Packages should declare an entry point in the `tangram_core.plugins` group
|
|
35
|
+
in their `pyproject.toml` pointing to an instance of this class.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
frontend_path: str | None = None
|
|
39
|
+
"""Path to the compiled frontend assets, *relative* to the distribution root
|
|
40
|
+
(editable) or package root (wheel).
|
|
41
|
+
"""
|
|
42
|
+
routers: list[APIRouter] = field(default_factory=list)
|
|
43
|
+
into_frontend_config_function: IntoFrontendConfigFunction | None = None
|
|
44
|
+
"""Function to parse plugin-scoped backend configuration (within the
|
|
45
|
+
`tangram.toml`) into a frontend-safe configuration object.
|
|
46
|
+
|
|
47
|
+
If not specified, the backend configuration dict is passed as-is."""
|
|
48
|
+
lifespan: Lifespan | None = None
|
|
49
|
+
"""Async context manager for plugin initialization and teardown."""
|
|
50
|
+
services: list[tuple[Priority, ServiceAsyncFunc]] = field(
|
|
51
|
+
default_factory=list, init=False
|
|
52
|
+
)
|
|
53
|
+
dist_name: str = field(init=False)
|
|
54
|
+
"""Name of the distribution (package) that provided this plugin, populated
|
|
55
|
+
automatically during loading.
|
|
56
|
+
""" # we do this so plugins can know their own package name if needed
|
|
57
|
+
|
|
58
|
+
def register_service(
|
|
59
|
+
self, priority: Priority = 0
|
|
60
|
+
) -> Callable[[ServiceFunc], ServiceFunc]:
|
|
61
|
+
"""Decorator to register a background service function.
|
|
62
|
+
|
|
63
|
+
Services are long-running async functions that receive the BackendState
|
|
64
|
+
and are started when the application launches.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def decorator(func: ServiceFunc) -> ServiceFunc:
|
|
68
|
+
@functools.wraps(func)
|
|
69
|
+
async def async_wrapper(backend_state: BackendState) -> None:
|
|
70
|
+
if asyncio.iscoroutinefunction(func):
|
|
71
|
+
await func(backend_state)
|
|
72
|
+
else:
|
|
73
|
+
await asyncio.to_thread(func, backend_state)
|
|
74
|
+
|
|
75
|
+
self.services.append((priority, async_wrapper))
|
|
76
|
+
return func
|
|
77
|
+
|
|
78
|
+
return decorator
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def scan_plugins() -> importlib.metadata.EntryPoints:
|
|
82
|
+
return importlib.metadata.entry_points(group="tangram_core.plugins")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def load_plugin(
|
|
86
|
+
entry_point: importlib.metadata.EntryPoint,
|
|
87
|
+
) -> Plugin | None:
|
|
88
|
+
"""Instantiates the plugin object defined in the entry point
|
|
89
|
+
and injects the name of the distribution into it."""
|
|
90
|
+
try:
|
|
91
|
+
plugin_instance = entry_point.load()
|
|
92
|
+
except Exception as e:
|
|
93
|
+
tb = traceback.format_exc()
|
|
94
|
+
logger.error(
|
|
95
|
+
f"failed to load plugin {entry_point.name}: {e}. {tb}"
|
|
96
|
+
f"\n= help: does {entry_point.value} exist?"
|
|
97
|
+
)
|
|
98
|
+
return None
|
|
99
|
+
if not isinstance(plugin_instance, Plugin):
|
|
100
|
+
logger.error(f"entry point {entry_point.name} is not an instance of `Plugin`")
|
|
101
|
+
return None
|
|
102
|
+
if entry_point.dist is None:
|
|
103
|
+
logger.error(f"could not determine distribution for plugin {entry_point.name}")
|
|
104
|
+
return None
|
|
105
|
+
# NOTE: we ignore `entry_point.name` for now and simply use the distribution's name
|
|
106
|
+
# should we raise an error if they differ? not for now
|
|
107
|
+
|
|
108
|
+
plugin_instance.dist_name = entry_point.dist.name
|
|
109
|
+
return plugin_instance
|
tangram_core/plugin.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { TangramApi } from "./api";
|
|
2
|
+
|
|
3
|
+
type PluginProgressStage = "manifest" | "plugin" | "done";
|
|
4
|
+
type PluginProgress = {
|
|
5
|
+
stage: PluginProgressStage;
|
|
6
|
+
pluginName?: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type PluginConfig = unknown; // to be casted by each plugin who consume it
|
|
10
|
+
|
|
11
|
+
export async function loadPlugins(
|
|
12
|
+
tangramApi: TangramApi,
|
|
13
|
+
onProgress?: (progress: PluginProgress) => void
|
|
14
|
+
) {
|
|
15
|
+
onProgress?.({ stage: "manifest" });
|
|
16
|
+
const manifest = await fetch("/manifest.json").then(res => res.json());
|
|
17
|
+
|
|
18
|
+
for (const [pluginName, meta] of Object.entries(manifest.plugins)) {
|
|
19
|
+
const pluginMeta = meta as {
|
|
20
|
+
main: string;
|
|
21
|
+
style?: string;
|
|
22
|
+
config?: PluginConfig;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
onProgress?.({ stage: "plugin", pluginName });
|
|
26
|
+
|
|
27
|
+
if (pluginMeta.style) {
|
|
28
|
+
const link = document.createElement("link");
|
|
29
|
+
link.rel = "stylesheet";
|
|
30
|
+
link.href = `/plugins/${pluginName}/${pluginMeta.style}`;
|
|
31
|
+
document.head.appendChild(link);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const entryPointUrl = `/plugins/${pluginName}/${pluginMeta.main}`;
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const pluginModule = await import(/* @vite-ignore */ entryPointUrl);
|
|
38
|
+
if (pluginModule.install) {
|
|
39
|
+
pluginModule.install(tangramApi, pluginMeta.config);
|
|
40
|
+
}
|
|
41
|
+
} catch (e) {
|
|
42
|
+
console.error(`failed to load plugin "${pluginName}":`, e);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
onProgress?.({ stage: "done" });
|
|
47
|
+
}
|
tangram_core/redis.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import abc
|
|
2
|
+
import asyncio
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Generic, List, TypeVar
|
|
5
|
+
|
|
6
|
+
from redis.asyncio import Redis
|
|
7
|
+
from redis.asyncio.client import PubSub
|
|
8
|
+
from redis.exceptions import RedisError
|
|
9
|
+
|
|
10
|
+
log = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
StateT = TypeVar("StateT")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Subscriber(abc.ABC, Generic[StateT]):
|
|
16
|
+
redis: Redis
|
|
17
|
+
task: asyncio.Task[None]
|
|
18
|
+
pubsub: PubSub
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self, name: str, redis_url: str, channels: List[str], initial_state: StateT
|
|
22
|
+
):
|
|
23
|
+
self.name = name
|
|
24
|
+
self.redis_url: str = redis_url
|
|
25
|
+
self.channels: List[str] = channels
|
|
26
|
+
self.state: StateT = initial_state
|
|
27
|
+
self._running = False
|
|
28
|
+
|
|
29
|
+
async def subscribe(self) -> None:
|
|
30
|
+
if self._running:
|
|
31
|
+
log.warning("%s already running", self.name)
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
self.redis = await Redis.from_url(self.redis_url)
|
|
36
|
+
self.pubsub = self.redis.pubsub()
|
|
37
|
+
await self.pubsub.psubscribe(*self.channels)
|
|
38
|
+
except RedisError as e:
|
|
39
|
+
log.error("%s failed to connect to Redis: %s", self.name, e)
|
|
40
|
+
raise
|
|
41
|
+
|
|
42
|
+
async def listen() -> None:
|
|
43
|
+
try:
|
|
44
|
+
log.info("%s listening ...", self.name)
|
|
45
|
+
async for message in self.pubsub.listen():
|
|
46
|
+
log.debug("message: %s", message)
|
|
47
|
+
if message["type"] == "pmessage":
|
|
48
|
+
await self.message_handler(
|
|
49
|
+
message["channel"].decode("utf-8"),
|
|
50
|
+
message["data"].decode("utf-8"),
|
|
51
|
+
message["pattern"].decode("utf-8"),
|
|
52
|
+
self.state,
|
|
53
|
+
)
|
|
54
|
+
except asyncio.CancelledError:
|
|
55
|
+
log.warning("%s cancelled", self.name)
|
|
56
|
+
|
|
57
|
+
self._running = True
|
|
58
|
+
|
|
59
|
+
self.task = asyncio.create_task(listen())
|
|
60
|
+
log.info("%s task created, running ...", self.name)
|
|
61
|
+
|
|
62
|
+
async def cleanup(self) -> None:
|
|
63
|
+
if not self._running:
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
if self.task:
|
|
67
|
+
log.debug("%s canceling task ...", self.name)
|
|
68
|
+
self.task.cancel()
|
|
69
|
+
try:
|
|
70
|
+
log.debug("%s await task to finish ...", self.name)
|
|
71
|
+
await self.task
|
|
72
|
+
log.debug("%s task canceled", self.name)
|
|
73
|
+
except asyncio.CancelledError as exc:
|
|
74
|
+
log.error("%s task canceling error: %s", self.name, exc)
|
|
75
|
+
if self.pubsub:
|
|
76
|
+
await self.pubsub.unsubscribe()
|
|
77
|
+
if self.redis:
|
|
78
|
+
await self.redis.close()
|
|
79
|
+
self._running = False
|
|
80
|
+
|
|
81
|
+
def is_active(self) -> bool:
|
|
82
|
+
"""Return True if the subscriber is actively listening."""
|
|
83
|
+
return self._running and self.task is not None and not self.task.done()
|
|
84
|
+
|
|
85
|
+
@abc.abstractmethod
|
|
86
|
+
async def message_handler(
|
|
87
|
+
self, event: str, payload: str, pattern: str, state: StateT
|
|
88
|
+
) -> None:
|
|
89
|
+
pass
|
tangram_core/user.css
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
html,
|
|
2
|
+
body,
|
|
3
|
+
#container-map {
|
|
4
|
+
height: 100%;
|
|
5
|
+
overflow: hidden;
|
|
6
|
+
width: 100%;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
#map {
|
|
10
|
+
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
|
|
11
|
+
height: 100%;
|
|
12
|
+
width: auto;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
#chart {
|
|
17
|
+
border: none;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
div.form {
|
|
21
|
+
float: right;
|
|
22
|
+
clear: right;
|
|
23
|
+
width: 300px;
|
|
24
|
+
border: 1px solid #bab0ac;
|
|
25
|
+
padding: 10px;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
body {
|
|
29
|
+
font-family: "B612", sans-serif;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
span.monospace {
|
|
33
|
+
font-family: "B612", sans-serif;
|
|
34
|
+
font-size: 85%;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.smaller td {
|
|
38
|
+
font-size: 95%;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
table a:not(.btn),
|
|
42
|
+
.table a:not(.btn) {
|
|
43
|
+
text-decoration: none;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
a.flag {
|
|
47
|
+
position: relative;
|
|
48
|
+
cursor: pointer;
|
|
49
|
+
color: #2c3e50;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
a.flag:hover::after {
|
|
53
|
+
content: attr(data-tooltip);
|
|
54
|
+
position: absolute;
|
|
55
|
+
bottom: 1.7em;
|
|
56
|
+
left: 0.5em;
|
|
57
|
+
border: 1px #18bc9c solid;
|
|
58
|
+
border-radius: 5px;
|
|
59
|
+
padding: 4px;
|
|
60
|
+
color: whitesmoke;
|
|
61
|
+
background-color: #18bc9c;
|
|
62
|
+
text-align: center;
|
|
63
|
+
font-size: 90%;
|
|
64
|
+
width: max-content;
|
|
65
|
+
z-index: 1;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.w20pc {
|
|
69
|
+
width: 20%;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.center {
|
|
73
|
+
margin-top: 20px;
|
|
74
|
+
margin-left: auto;
|
|
75
|
+
margin-right: auto;
|
|
76
|
+
/* max-width: 1000px; */
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.copyright_label {
|
|
80
|
+
display: inline;
|
|
81
|
+
padding: 0.2em 0.6em 0.3em;
|
|
82
|
+
font-size: 75%;
|
|
83
|
+
font-weight: 400;
|
|
84
|
+
line-height: 1;
|
|
85
|
+
color: #fff;
|
|
86
|
+
text-align: center;
|
|
87
|
+
white-space: nowrap;
|
|
88
|
+
vertical-align: baseline;
|
|
89
|
+
border-radius: 0.25em;
|
|
90
|
+
position: absolute;
|
|
91
|
+
bottom: 0;
|
|
92
|
+
z-index: 1;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.turb_selected {
|
|
96
|
+
stroke: red;
|
|
97
|
+
stroke-width: 15px;
|
|
98
|
+
z-index: -1;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.form-popup {
|
|
102
|
+
display: none;
|
|
103
|
+
position: fixed;
|
|
104
|
+
bottom: 0;
|
|
105
|
+
right: 15px;
|
|
106
|
+
border: 3px solid #f1f1f1;
|
|
107
|
+
z-index: 9;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.form-container {
|
|
111
|
+
max-width: 300px;
|
|
112
|
+
padding: 10px;
|
|
113
|
+
background-color: white;
|
|
114
|
+
}
|
tangram_core/utils.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// adapted from: https://github.com/color-js/color.js/blob/main/src/spaces/oklch.js
|
|
2
|
+
type Vector3 = [number, number, number];
|
|
3
|
+
|
|
4
|
+
const multiplyMatrices = (A: number[], B: Vector3): Vector3 => {
|
|
5
|
+
return [
|
|
6
|
+
A[0] * B[0] + A[1] * B[1] + A[2] * B[2],
|
|
7
|
+
A[3] * B[0] + A[4] * B[1] + A[5] * B[2],
|
|
8
|
+
A[6] * B[0] + A[7] * B[1] + A[8] * B[2]
|
|
9
|
+
];
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const oklch2oklab = ([l, c, h]: Vector3): Vector3 => [
|
|
13
|
+
l,
|
|
14
|
+
isNaN(h) ? 0 : c * Math.cos((h * Math.PI) / 180),
|
|
15
|
+
isNaN(h) ? 0 : c * Math.sin((h * Math.PI) / 180)
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
const srgbLinear2rgb = (rgb: Vector3): Vector3 =>
|
|
19
|
+
rgb.map(c =>
|
|
20
|
+
Math.abs(c) > 0.0031308
|
|
21
|
+
? (c < 0 ? -1 : 1) * (1.055 * Math.pow(Math.abs(c), 1 / 2.4) - 0.055)
|
|
22
|
+
: 12.92 * c
|
|
23
|
+
) as Vector3;
|
|
24
|
+
|
|
25
|
+
const oklab2xyz = (lab: Vector3): Vector3 => {
|
|
26
|
+
const LMSg = multiplyMatrices(
|
|
27
|
+
[
|
|
28
|
+
1, 0.3963377773761749, 0.2158037573099136, 1, -0.1055613458156586,
|
|
29
|
+
-0.0638541728258133, 1, -0.0894841775298119, -1.2914855480194092
|
|
30
|
+
],
|
|
31
|
+
lab
|
|
32
|
+
);
|
|
33
|
+
const LMS = LMSg.map(val => val ** 3) as Vector3;
|
|
34
|
+
return multiplyMatrices(
|
|
35
|
+
[
|
|
36
|
+
1.2268798758459243, -0.5578149944602171, 0.2813910456659647, -0.0405757452148008,
|
|
37
|
+
1.112286803280317, -0.0717110580655164, -0.0763729366746601, -0.4214933324022432,
|
|
38
|
+
1.5869240198367816
|
|
39
|
+
],
|
|
40
|
+
LMS
|
|
41
|
+
);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const xyz2rgbLinear = (xyz: Vector3): Vector3 => {
|
|
45
|
+
return multiplyMatrices(
|
|
46
|
+
[
|
|
47
|
+
3.2409699419045226, -1.537383177570094, -0.4986107602930034, -0.9692436362808796,
|
|
48
|
+
1.8759675015077202, 0.04155505740717559, 0.05563007969699366,
|
|
49
|
+
-0.20397695888897652, 1.0569715142428786
|
|
50
|
+
],
|
|
51
|
+
xyz
|
|
52
|
+
);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const oklch2rgb = (lch: Vector3): Vector3 =>
|
|
56
|
+
srgbLinear2rgb(xyz2rgbLinear(oklab2xyz(oklch2oklab(lch))));
|
|
57
|
+
|
|
58
|
+
export function oklchToDeckGLColor(
|
|
59
|
+
l: number,
|
|
60
|
+
c: number,
|
|
61
|
+
h: number,
|
|
62
|
+
a: number = 255
|
|
63
|
+
): [number, number, number, number] {
|
|
64
|
+
const rgb = oklch2rgb([l, c, h]);
|
|
65
|
+
return [
|
|
66
|
+
Math.max(0, Math.min(255, Math.round(rgb[0] * 255))),
|
|
67
|
+
Math.max(0, Math.min(255, Math.round(rgb[1] * 255))),
|
|
68
|
+
Math.max(0, Math.min(255, Math.round(rgb[2] * 255))),
|
|
69
|
+
a
|
|
70
|
+
];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const formatTime = (ts: string) => {
|
|
74
|
+
const d = new Date(ts + "Z");
|
|
75
|
+
return (
|
|
76
|
+
d.toLocaleString("en-GB", {
|
|
77
|
+
timeZone: "UTC",
|
|
78
|
+
month: "short",
|
|
79
|
+
day: "numeric",
|
|
80
|
+
hour: "2-digit",
|
|
81
|
+
minute: "2-digit"
|
|
82
|
+
}) + "Z"
|
|
83
|
+
);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export const formatDuration = (sec: number) => {
|
|
87
|
+
const h = Math.floor(sec / 3600);
|
|
88
|
+
const m = Math.floor((sec % 3600) / 60);
|
|
89
|
+
return `${h}h ${m}m`;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export type Position3D = [number, number, number];
|
|
93
|
+
export interface PathSegment<ColorT> {
|
|
94
|
+
path: Position3D[];
|
|
95
|
+
colors: ColorT[];
|
|
96
|
+
dashed: boolean;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function* generateSegments<T, ColorT>(
|
|
100
|
+
data: Iterable<T>,
|
|
101
|
+
opts: {
|
|
102
|
+
getPosition: (d: T) => Position3D | null;
|
|
103
|
+
getTimestamp: (d: T) => number | null;
|
|
104
|
+
getColor: (d: T) => ColorT;
|
|
105
|
+
gapColor: ColorT;
|
|
106
|
+
maxGapSeconds: number;
|
|
107
|
+
}
|
|
108
|
+
): Generator<PathSegment<ColorT>> {
|
|
109
|
+
let segment: PathSegment<ColorT> = { path: [], colors: [], dashed: false };
|
|
110
|
+
let lastT: number | null = null;
|
|
111
|
+
let lastPos: Position3D | null = null;
|
|
112
|
+
|
|
113
|
+
for (const d of data) {
|
|
114
|
+
const pos = opts.getPosition(d);
|
|
115
|
+
if (!pos) continue;
|
|
116
|
+
|
|
117
|
+
const t = opts.getTimestamp(d);
|
|
118
|
+
|
|
119
|
+
if (lastT !== null && t !== null && lastPos !== null) {
|
|
120
|
+
if (Math.abs(t - lastT) > opts.maxGapSeconds) {
|
|
121
|
+
if (segment.path.length > 1) {
|
|
122
|
+
yield segment;
|
|
123
|
+
}
|
|
124
|
+
yield {
|
|
125
|
+
path: [lastPos, pos],
|
|
126
|
+
colors: [opts.gapColor, opts.gapColor],
|
|
127
|
+
dashed: true
|
|
128
|
+
};
|
|
129
|
+
segment = { path: [], colors: [], dashed: false };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
segment.path.push(pos);
|
|
134
|
+
segment.colors.push(opts.getColor(d));
|
|
135
|
+
|
|
136
|
+
lastT = t;
|
|
137
|
+
lastPos = pos;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (segment.path.length > 1) {
|
|
141
|
+
yield segment;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// not using typescript because of an annoying vite bug:
|
|
2
|
+
// - https://github.com/vitejs/vite/issues/5370
|
|
3
|
+
// - https://github.com/vitejs/vite/issues/16040
|
|
4
|
+
// essentially, vite's on-the-fly transpilation is scoped only to the config
|
|
5
|
+
// file itself. therefore vite plugins in a monorepo cannot be typescript.
|
|
6
|
+
import vue from "@vitejs/plugin-vue";
|
|
7
|
+
import path from "path";
|
|
8
|
+
import fs from "fs/promises";
|
|
9
|
+
|
|
10
|
+
const DECKGL_PACKAGES = [
|
|
11
|
+
"@deck.gl/core",
|
|
12
|
+
"@deck.gl/layers",
|
|
13
|
+
"@deck.gl/aggregation-layers",
|
|
14
|
+
"@deck.gl/geo-layers",
|
|
15
|
+
"@deck.gl/mesh-layers",
|
|
16
|
+
"@deck.gl/json",
|
|
17
|
+
"@deck.gl/mapbox",
|
|
18
|
+
"@deck.gl/widgets",
|
|
19
|
+
"@deck.gl/extensions"
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {{ copyToPythonPackage?: boolean; pythonPackageDir?: string; includePackageJson?: boolean }} [options]
|
|
24
|
+
* If the plugin is built with maturin, `copyToPythonPackage` should be 'true' to
|
|
25
|
+
* avoid the built binary distribution wheel omitting the 'dist-frontend'.
|
|
26
|
+
* See: https://github.com/open-aviation/tangram/pull/99#issuecomment-3777038726
|
|
27
|
+
* @returns {import('vite').Plugin[]}
|
|
28
|
+
*/
|
|
29
|
+
export function tangramPlugin(options = {}) {
|
|
30
|
+
const projectRoot = process.cwd();
|
|
31
|
+
const copyToPythonPackage = options.copyToPythonPackage ?? false;
|
|
32
|
+
const includePackageJson = options.includePackageJson ?? true;
|
|
33
|
+
/** @type {{ name: string; main: string; }} */
|
|
34
|
+
let pkg;
|
|
35
|
+
let entryFileName;
|
|
36
|
+
|
|
37
|
+
/** @type {import('vite').Plugin} */
|
|
38
|
+
const configInjector = {
|
|
39
|
+
name: "tangram-plugin-config-injector",
|
|
40
|
+
async config() {
|
|
41
|
+
const pkgPath = path.resolve(projectRoot, "package.json");
|
|
42
|
+
pkg = JSON.parse(await fs.readFile(pkgPath, "utf-8"));
|
|
43
|
+
|
|
44
|
+
if (!pkg.main) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`\`main\` field must be specified in ${pkg.name}'s package.json`
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
entryFileName = path.parse(pkg.main).name;
|
|
50
|
+
|
|
51
|
+
/** @type {import('vite').UserConfig} */
|
|
52
|
+
const tangramBuildConfig = {
|
|
53
|
+
build: {
|
|
54
|
+
sourcemap: true,
|
|
55
|
+
lib: {
|
|
56
|
+
entry: pkg.main,
|
|
57
|
+
fileName: entryFileName,
|
|
58
|
+
formats: ["es"]
|
|
59
|
+
},
|
|
60
|
+
rollupOptions: {
|
|
61
|
+
external: [
|
|
62
|
+
"vue",
|
|
63
|
+
"maplibre",
|
|
64
|
+
...DECKGL_PACKAGES,
|
|
65
|
+
"lit-html",
|
|
66
|
+
"rs1090-wasm",
|
|
67
|
+
"parquet-wasm"
|
|
68
|
+
]
|
|
69
|
+
},
|
|
70
|
+
outDir: "dist-frontend",
|
|
71
|
+
minify: true
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
return tangramBuildConfig;
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** @type {import('vite').Plugin} */
|
|
79
|
+
const manifestGenerator = {
|
|
80
|
+
name: "tangram-manifest-generator",
|
|
81
|
+
apply: "build",
|
|
82
|
+
async writeBundle(outputOptions, bundle) {
|
|
83
|
+
const outDir = outputOptions.dir;
|
|
84
|
+
const cssAsset = Object.values(bundle).find(
|
|
85
|
+
asset => asset.type === "asset" && asset.fileName.endsWith(".css")
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const manifest = {
|
|
89
|
+
name: pkg.name,
|
|
90
|
+
main: `${entryFileName}.js`,
|
|
91
|
+
...(cssAsset && { style: cssAsset.fileName })
|
|
92
|
+
};
|
|
93
|
+
await fs.writeFile(
|
|
94
|
+
path.resolve(outDir, "plugin.json"),
|
|
95
|
+
JSON.stringify(manifest, null, 2)
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/** @type {import('vite').Plugin} */
|
|
101
|
+
const pythonPackageSync = {
|
|
102
|
+
name: "tangram-python-package-sync",
|
|
103
|
+
apply: "build",
|
|
104
|
+
async writeBundle(outputOptions) {
|
|
105
|
+
if (!copyToPythonPackage) return;
|
|
106
|
+
|
|
107
|
+
const outDir = outputOptions.dir
|
|
108
|
+
? path.resolve(projectRoot, outputOptions.dir)
|
|
109
|
+
: path.resolve(projectRoot, "dist-frontend");
|
|
110
|
+
const pythonPackageDir = options.pythonPackageDir
|
|
111
|
+
? path.resolve(projectRoot, options.pythonPackageDir)
|
|
112
|
+
: path.resolve(projectRoot, path.dirname(pkg.main));
|
|
113
|
+
const distDst = path.join(pythonPackageDir, "dist-frontend");
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
await fs.stat(outDir);
|
|
117
|
+
} catch {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
await fs.rm(distDst, { recursive: true, force: true });
|
|
122
|
+
await copyDirRecursive(outDir, distDst);
|
|
123
|
+
|
|
124
|
+
if (includePackageJson) {
|
|
125
|
+
await fs.copyFile(
|
|
126
|
+
path.resolve(projectRoot, "package.json"),
|
|
127
|
+
path.join(pythonPackageDir, "package.json")
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
return [vue(), configInjector, manifestGenerator, pythonPackageSync];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* @param {string} src
|
|
138
|
+
* @param {string} dst
|
|
139
|
+
*/
|
|
140
|
+
async function copyDirRecursive(src, dst) {
|
|
141
|
+
await fs.mkdir(dst, { recursive: true });
|
|
142
|
+
const entries = await fs.readdir(src, { withFileTypes: true });
|
|
143
|
+
|
|
144
|
+
for (const entry of entries) {
|
|
145
|
+
const srcPath = path.join(src, entry.name);
|
|
146
|
+
const dstPath = path.join(dst, entry.name);
|
|
147
|
+
|
|
148
|
+
if (entry.isDirectory()) {
|
|
149
|
+
await copyDirRecursive(srcPath, dstPath);
|
|
150
|
+
} else if (entry.isFile()) {
|
|
151
|
+
await fs.mkdir(path.dirname(dstPath), { recursive: true });
|
|
152
|
+
await fs.copyFile(srcPath, dstPath);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|