diff --git a/libs/core/CMakeLists.txt b/libs/core/CMakeLists.txt index 821d47c1..43c7eb20 100644 --- a/libs/core/CMakeLists.txt +++ b/libs/core/CMakeLists.txt @@ -24,6 +24,7 @@ if(${CMAKE_SYSTEM_NAME} STREQUAL "Emscripten") add_executable(erdblick-core ${ERDBLICK_SOURCE_FILES}) set_target_properties(erdblick-core PROPERTIES LINK_FLAGS "\ --bind \ + --profiling \ -s ENVIRONMENT=web \ -s MODULARIZE=1 \ -s EXPORT_ES6=1 \ @@ -52,4 +53,3 @@ target_link_libraries(erdblick-core glm mapget-model yaml-cpp) - diff --git a/libs/core/include/erdblick/aabb.h b/libs/core/include/erdblick/aabb.h index 6bf7be74..db58ca08 100644 --- a/libs/core/include/erdblick/aabb.h +++ b/libs/core/include/erdblick/aabb.h @@ -20,8 +20,6 @@ using Wgs84Point = mapget::Point; class Wgs84AABB { public: - static TilePriorityFn radialDistancePrioFn(glm::vec2 camPos, float orientation); - using vec2_t = glm::dvec2; Wgs84AABB() = default; @@ -58,8 +56,9 @@ class Wgs84AABB /** Obtain the size of this bounding box. */ vec2_t const& size() const { return size_; } - /** Determine whether the horizontal extent of this bounding rect - * crosses the anti-meridian (lon == +/- 180°). + /** + * Determine whether the horizontal extent of this bounding rect + * crosses the anti-meridian (lon == +/- 180°). */ bool containsAntiMeridian() const; @@ -93,17 +92,24 @@ class Wgs84AABB /** Determine whether this bounding rect has an intersection with another bounding rect. */ bool intersects(Wgs84AABB const& other) const; - /** Obtain TileIds for a given tile level. + /** + * Obtain TileIds for a given tile level. Will fill in tileIds for the + * given level into resultTileIdsWithPriority up to resultTileIdsWithPriority.capacity(), + * so the function is guaranteed not to allocate any heap memory. + * Also annotates each returned TileId with a float as returned by the + * TilePenaltyFn lambda. */ - void tileIds(uint16_t level, std::vector &result) const; + void tileIdsWithPriority( + uint16_t level, + std::vector> &resultTileIdsWithPriority, + TilePriorityFn const& prioFn) const; - /** Same as tileIdsWithPriority, but strips the priority values - * and converts the linked list to a vector. + /** + * Returns a tile priority function based on the given camera position + * in WGS84, and orientation (bearing) in Radians. This priority function + * may be plugged into tileIdsWithPriority. */ - std::vector tileIds( - uint16_t level, - std::function const& tilePenaltyFun, - size_t limit) const; + static TilePriorityFn radialDistancePrioFn(glm::vec2 const& camPos, float orientation); private: vec2_t sw_{.0, .0}; diff --git a/libs/core/include/erdblick/renderer.h b/libs/core/include/erdblick/renderer.h index 7bbb9adc..9f9f6273 100644 --- a/libs/core/include/erdblick/renderer.h +++ b/libs/core/include/erdblick/renderer.h @@ -13,8 +13,10 @@ class FeatureLayerRenderer FeatureLayerRenderer(); /** - * Convert a TileFeatureLayer to a GLB buffer. - * Returns the cartesian origin of the tile. + * Convert a TileFeatureLayer to a GLB buffer. Returns the + * cartesian origin of the tile. If there are no features to render, + * either because the layer is empty or because no style rule matched, + * then the size of the result buffer will be zero. */ mapget::Point render( const FeatureLayerStyle& style, diff --git a/libs/core/include/erdblick/stream.h b/libs/core/include/erdblick/stream.h index db5d53da..a41a9974 100644 --- a/libs/core/include/erdblick/stream.h +++ b/libs/core/include/erdblick/stream.h @@ -9,13 +9,51 @@ namespace erdblick class TileLayerParser { public: - explicit TileLayerParser(SharedUint8Array const& dataSourceInfo); - void onTileParsed(std::function); - void parse(SharedUint8Array const& dataSourceInfo); + explicit TileLayerParser(); + + /** + * Update the data source info metadata which the parser uses + * to supply parsed TileFeatureLayers with map metadata info. + */ + void setDataSourceInfo(SharedUint8Array const& dataSourceInfoJson); + + /** + * Serialize a TileFeatureLayer to a buffer. + */ + void writeTileFeatureLayer(mapget::TileFeatureLayer::Ptr const& tile, SharedUint8Array& buffer); + + /** + * Parse a TileFeatureLayer from a buffer as returned by writeTileFeatureLayer. + */ + mapget::TileFeatureLayer::Ptr readTileFeatureLayer(SharedUint8Array const& buffer); + + /** + * Reset the parser by removing any buffered unparsed stream chunks. + */ + void reset(); + + /** + * Access the field id dictionary offsets as currently known by this parser. + * This is used to tell the server whether additional field-id mapping updates + * need to be sent. + */ + mapget::TileLayerStream::FieldOffsetMap fieldDictOffsets(); + + /** + * Stream-based parsing functionality: Set callback which is called + * as soon as a tile has been parsed. + */ + void onTileParsedFromStream(std::function); + + /** + * Add a chunk of streamed data into this TileLayerParser. + */ + void parseFromStream(SharedUint8Array const& buffer); private: std::map info_; std::unique_ptr reader_; + std::shared_ptr cachedFieldDicts_; std::function tileParsedFun_; }; diff --git a/libs/core/src/aabb.cpp b/libs/core/src/aabb.cpp index 9f771bf3..6a8d0653 100644 --- a/libs/core/src/aabb.cpp +++ b/libs/core/src/aabb.cpp @@ -1,5 +1,7 @@ #include "aabb.h" +#include "glm/ext.hpp" + namespace erdblick { @@ -16,33 +18,6 @@ inline Wgs84Point point(glm::dvec3 const& p) return {p.x, p.y, p.z}; } -float fastAtan2(float y, float x) -{ - if (x == 0.0 && y == 0.0) { - return 0.0; // handle the case when both x and y are zero - } - - float abs_x = std::abs(x); - float abs_y = std::abs(y); - - float a = std::min(abs_x, abs_y) / std::max(abs_x, abs_y); - float s = a * a; - - float r = ((-0.0464964749f * s + 0.15931422f) * s - 0.327622764f) * s * a + a; - - if (abs_y > abs_x) { - r = 1.57079637f - r; - } - if (x < 0.0) { - r = 3.14159274f - r; - } - if (y < 0.0) { - r = -r; - } - - return r; -} - } // namespace Wgs84AABB::Wgs84AABB(const Wgs84Point& sw, glm::dvec2 size) : sw_(sw.x, sw.y), size_(size) @@ -165,15 +140,18 @@ bool Wgs84AABB::intersects(const Wgs84AABB& other) const contains(other.nw()) || other.intersects(*this); } -void Wgs84AABB::tileIds(uint16_t level, std::vector& tileIdsResult) const +void Wgs84AABB::tileIdsWithPriority( + uint16_t level, + std::vector> &tileIdsResult, + TilePriorityFn const& prioFn) const { if (containsAntiMeridian()) { auto normalizedViewports = splitOverAntiMeridian(); assert( !normalizedViewports.first.containsAntiMeridian() && !normalizedViewports.second.containsAntiMeridian()); - normalizedViewports.first.tileIds(level, tileIdsResult); - normalizedViewports.second.tileIds(level, tileIdsResult); + normalizedViewports.first.tileIdsWithPriority(level, tileIdsResult, prioFn); + normalizedViewports.second.tileIdsWithPriority(level, tileIdsResult, prioFn); } auto const tileWidth = 180. / static_cast(1 << level); @@ -191,7 +169,7 @@ void Wgs84AABB::tileIds(uint16_t level, std::vector& tileIdsResult) cons double y = minPoint.y; while (y <= maxPoint.y && remainingCapacity > 0) { auto tid = TileId::fromWgs84(x, y, level); - tileIdsResult.emplace_back(tid); + tileIdsResult.emplace_back(tid, prioFn(tid)); remainingCapacity -= 1; y += glm::min(tileWidth, glm::max(maxPoint.y - y, epsilon)); } @@ -199,23 +177,22 @@ void Wgs84AABB::tileIds(uint16_t level, std::vector& tileIdsResult) cons } } -TilePriorityFn Wgs84AABB::radialDistancePrioFn(glm::vec2 camPos, float orientation) +TilePriorityFn Wgs84AABB::radialDistancePrioFn(glm::vec2 const& camPos, float orientation) { return [camPos, orientation](TileId const& tid) { auto center = tid.center(); - float xDiff = center.x - camPos.x; - float yDiff = center.y - camPos.y; + float xDiff = static_cast(center.x) - camPos.x; + float yDiff = static_cast(center.y) - camPos.y; auto angle = glm::atan(yDiff, xDiff); // Angle to east (x axis) direction. glm::atan is atan2. - angle -= orientation + - M_PI_2; // Difference w/ compass direction normalized from North to East - angle = glm::abs(glm::mod(angle, (float)M_2_PI)); // Map angle to circle - if (angle > M_PI) - angle = M_2_PI - angle; + angle -= orientation + glm::two_pi(); // Difference w/ compass direction normalized from North to East + angle = glm::abs(glm::mod(angle, glm::two_pi())); // Map angle to circle + if (angle > glm::pi()) + angle = glm::two_pi() - angle; - auto distance = yDiff + xDiff; // eventually use manhattan distance to avoid comp overhead? - return yDiff + xDiff + angle * distance; + auto distance = glm::sqrt(yDiff*yDiff + xDiff*xDiff); + return distance + angle * distance; }; } diff --git a/libs/core/src/bindings.cpp b/libs/core/src/bindings.cpp index 2dc93cd2..bd5d45d9 100644 --- a/libs/core/src/bindings.cpp +++ b/libs/core/src/bindings.cpp @@ -12,13 +12,93 @@ using namespace erdblick; namespace em = emscripten; -Wgs84AABB createWgs84AABB(float x, float y, uint32_t softLimit, uint16_t level) +/** + * WGS84 Viewport Descriptor, which may be used with the + * `getTileIds` function below. + */ +struct Viewport { + double south = .0; // The southern boundary of the viewport (degrees). + double west = .0; // The western boundary of the viewport (degrees). + double width = .0; // The width of the viewport (degrees). + double height = .0; // The height of the viewport (degrees). + double camPosLon = .0; // The longitude of the camera position (degrees). + double camPosLat = .0; // The latitude of the camera position (degrees). + double orientation = .0; // The compass orientation of the camera (radians). +}; + +/** + * Gets the prioritized list of tile IDs for a given viewport, zoom level, and tile limit. + * + * This function takes a viewport, a zoom level, and a tile limit, and returns an array of tile IDs + * that are visible in the viewport, prioritized by radial distance from the camera position. + * + * The function first extracts the viewport properties and creates an Axis-Aligned Bounding Box (AABB) + * from the viewport boundaries. If the number of tile IDs in the AABB at the given zoom level exceeds + * the specified limit, a new AABB is created from the camera position and tile limit. + * + * The function then populates a vector of prioritized tile IDs by calculating the radial distance + * from the camera position to the center of each tile in the AABB. The tile IDs are then sorted by + * their radial distance, and the sorted array is converted to an emscripten value to be returned. + * Duplicate tile IDs are removed from the array before it is returned. + * + * @param viewport The viewport descriptor for which tile ids are needed. + * @param level The zoom level for which to get the tile IDs. + * @param limit The maximum number of tile IDs to return. + * + * @return An emscripten value representing an array of prioritized tile IDs. + */ +em::val getTileIds(Viewport const& vp, int level, int limit) { - return Wgs84AABB::fromCenterAndTileLimit(Wgs84Point{x, y, 0}, softLimit, level); + Wgs84AABB aabb(Wgs84Point{vp.west, vp.south, .0}, {vp.width, vp.height}); + if (aabb.numTileIds(level) > limit) + // Create a size-limited AABB from the tile limit. + aabb = Wgs84AABB::fromCenterAndTileLimit(Wgs84Point{vp.camPosLon, vp.camPosLat, .0}, limit, level); + + std::vector> prioritizedTileIds; + prioritizedTileIds.reserve(limit); + aabb.tileIdsWithPriority( + level, + prioritizedTileIds, + Wgs84AABB::radialDistancePrioFn({vp.camPosLon, vp.camPosLat}, vp.orientation)); + + std::sort( + prioritizedTileIds.begin(), + prioritizedTileIds.end(), + [](auto const& l, auto const& r) { return l.second < r.second; }); + + em::val resultArray = em::val::array(); + int64_t prevTileId = -1; + for (const auto& tileId : prioritizedTileIds) { + if (tileId.first.value_ == prevTileId) + continue; + resultArray.call("push", tileId.first.value_); + prevTileId = tileId.first.value_; + } + + return resultArray; +} + +/** Get the center position for a mapget tile id in WGS84. */ +mapget::Point getTilePosition(uint64_t tileIdValue) { + mapget::TileId tid(tileIdValue); + return tid.center(); +} + +/** Get the full string key of a map tile feature layer. */ +std::string getTileFeatureLayerKey(std::string const& mapId, std::string const& layerId, uint64_t tileId) { + auto tileKey = mapget::MapTileKey(); + tileKey.layer_ = mapget::LayerType::Features; + tileKey.mapId_ = mapId; + tileKey.layerId_ = layerId; + tileKey.tileId_ = tileId; + return tileKey.toString(); } EMSCRIPTEN_BINDINGS(FeatureLayerRendererBind) { + // Activate this to see a lot more output from the WASM lib. + // mapget::log().set_level(spdlog::level::debug); + ////////// SharedUint8Array em::class_("SharedUint8Array") .constructor() @@ -27,7 +107,20 @@ EMSCRIPTEN_BINDINGS(FeatureLayerRendererBind) .function("getPointer", &SharedUint8Array::getPointer); ////////// Point - em::class_("Point"); + em::value_object("Point") + .field("x", &mapget::Point::x) + .field("y", &mapget::Point::y) + .field("z", &mapget::Point::z); + + ////////// Viewport + em::value_object("Viewport") + .field("south", &Viewport::south) + .field("west", &Viewport::west) + .field("width", &Viewport::width) + .field("height", &Viewport::height) + .field("camPosLon", &Viewport::camPosLon) + .field("camPosLat", &Viewport::camPosLat) + .field("orientation", &Viewport::orientation); ////////// FeatureLayerStyle em::class_("FeatureLayerStyle").constructor(); @@ -53,6 +146,10 @@ EMSCRIPTEN_BINDINGS(FeatureLayerRendererBind) "id", std::function( [](mapget::TileFeatureLayer const& self) { return self.id().toString(); })) + .function( + "tileId", + std::function( + [](mapget::TileFeatureLayer const& self) { return self.tileId().value_; })) .function( "center", std::function( @@ -89,75 +186,32 @@ EMSCRIPTEN_BINDINGS(FeatureLayerRendererBind) ////////// TileLayerParser em::class_("TileLayerParser") - .constructor() + .constructor<>() + .function("setDataSourceInfo", &TileLayerParser::setDataSourceInfo) .function( - "onTileParsed", + "onTileParsedFromStream", std::function( [](TileLayerParser& self, em::val cb) - { self.onTileParsed([cb](auto&& tile) { cb(tile); }); })) - .function("parse", &TileLayerParser::parse); - - ////////// Wgs84AABB - em::register_vector("VectorUint64"); - em::register_vector("VectorDouble"); - em::class_("Wgs84AABB") + { self.onTileParsedFromStream([cb](auto&& tile) { cb(tile); }); })) + .function("parseFromStream", &TileLayerParser::parseFromStream) + .function("reset", &TileLayerParser::reset) .function( - "tileIds", - std::function( - [](Wgs84AABB& self, - double camX, - double camY, - double camOrientation, - uint32_t level, - uint32_t limit) -> em::val + "fieldDictOffsets", + std::function( + [](TileLayerParser& self) { - // TODO: This tileIds-implementation is a work-in-progress - std::vector resultTiles; - resultTiles.reserve(limit); - self.tileIds(level, resultTiles); - - std::vector tileIdArray; - std::vector xArray; - std::vector yArray; - tileIdArray.reserve(resultTiles.size()); - xArray.reserve(resultTiles.size()); - yArray.reserve(resultTiles.size()); - for (auto& tile : resultTiles) { - tileIdArray.emplace_back((int64_t)tile.value_); - auto pos = tile.center(); - xArray.emplace_back(pos.x); - yArray.emplace_back(pos.y); - } - - em::val result = em::val::object(); - result.set("id", tileIdArray); - result.set("x", xArray); - result.set("y", yArray); - + auto result = em::val::object(); + for (auto const& [nodeId, fieldId] : self.fieldDictOffsets()) + result.set(nodeId, fieldId); return result; - // return em::val(resultWithPrio.size()); })) - .function( - "ne", - std::function( - [](Wgs84AABB& self) -> em::val - { - Wgs84Point pt = self.ne(); - em::val list = em::val::array(); - list.set(0, pt.x); - list.set(1, pt.y); - return list; - })) - .function( - "sw", - std::function( - [](Wgs84AABB& self) -> em::val - { - Wgs84Point pt = self.sw(); - em::val list = em::val::array(); - list.set(0, pt.x); - list.set(1, pt.y); - return list; - })); - function("createWgs84AABB", &createWgs84AABB, em::allow_raw_pointers()); + .function("readTileFeatureLayer", &TileLayerParser::readTileFeatureLayer) + .function("writeTileFeatureLayer", &TileLayerParser::writeTileFeatureLayer); + + ////////// Viewport TileID calculation + em::function("getTileIds", &getTileIds); + em::function("getTilePosition", &getTilePosition); + + ////////// Get full id of a TileFeatureLayer + em::function("getTileFeatureLayerKey", &getTileFeatureLayerKey); } diff --git a/libs/core/src/renderer.cpp b/libs/core/src/renderer.cpp index 14490bea..6ad47825 100644 --- a/libs/core/src/renderer.cpp +++ b/libs/core/src/renderer.cpp @@ -276,6 +276,9 @@ mapget::Point FeatureLayerRenderer::render( // NOLINT (render can be made stati } globalBufferSize += featureIdBuffer.size() * sizeof(uint32_t); + if (geomForRule.empty()) + return {tileOrigin.x, tileOrigin.y, tileOrigin.z}; + // Convert to GLTF std::vector buffer; buffer.resize(globalBufferSize); diff --git a/libs/core/src/stream.cpp b/libs/core/src/stream.cpp index 81ded513..3fc05713 100644 --- a/libs/core/src/stream.cpp +++ b/libs/core/src/stream.cpp @@ -6,17 +6,47 @@ using namespace mapget; namespace erdblick { -TileLayerParser::TileLayerParser(SharedUint8Array const& dataSourceInfo) +TileLayerParser::TileLayerParser() { - // Parse data source info - auto srcInfoParsed = nlohmann::json::parse(dataSourceInfo.toString()); + // Create field dict cache + cachedFieldDicts_ = std::make_shared(); + + // Create fresh mapget stream parser. + reset(); +} +void TileLayerParser::setDataSourceInfo(const erdblick::SharedUint8Array& dataSourceInfoJson) +{ + // Parse data source info + auto srcInfoParsed = nlohmann::json::parse(dataSourceInfoJson.toString()); for (auto const& node : srcInfoParsed) { auto dsInfo = DataSourceInfo::fromJson(node); info_.emplace(dsInfo.mapId_, std::move(dsInfo)); } +} - // Create parser +void TileLayerParser::onTileParsedFromStream(std::function fun) +{ + tileParsedFun_ = std::move(fun); +} + +void TileLayerParser::parseFromStream(SharedUint8Array const& bytes) +{ + try { + reader_->read(bytes.toString()); + } + catch(std::exception const& e) { + std::cout << "ERROR: " << e.what() << std::endl; + } +} + +mapget::TileLayerStream::FieldOffsetMap TileLayerParser::fieldDictOffsets() +{ + return reader_->fieldDictCache()->fieldDictOffsets(); +} + +void TileLayerParser::reset() +{ reader_ = std::make_unique( [this](auto&& mapId, auto&& layerId){ return info_[std::string(mapId)].getLayer(std::string(layerId)); @@ -24,22 +54,29 @@ TileLayerParser::TileLayerParser(SharedUint8Array const& dataSourceInfo) [this](auto&& layer){ if (tileParsedFun_) tileParsedFun_(layer); - }); + }, + cachedFieldDicts_); } -void TileLayerParser::onTileParsed(std::function fun) +void TileLayerParser::writeTileFeatureLayer( // NOLINT (Could be made static, but not due to Embind) + mapget::TileFeatureLayer::Ptr const& tile, + SharedUint8Array& buffer) { - tileParsedFun_ = std::move(fun); + std::stringstream serializedTile; + tile->write(serializedTile); + buffer.writeToArray(serializedTile.str()); } -void TileLayerParser::parse(SharedUint8Array const& dataSourceInfo) +mapget::TileFeatureLayer::Ptr TileLayerParser::readTileFeatureLayer(const SharedUint8Array& buffer) { - try { - reader_->read(dataSourceInfo.toString()); - } - catch(std::exception const& e) { - std::cout << "ERROR: " << e.what() << std::endl; - } + std::stringstream inputStream; + inputStream << buffer.toString(); + auto result = std::make_shared( + inputStream, + [this](auto&& mapId, auto&& layerId) + { return info_[std::string(mapId)].getLayer(std::string(layerId)); }, + [this](auto&& nodeId) { return cachedFieldDicts_->operator()(nodeId); }); + return result; } } diff --git a/static/erdblick/features.js b/static/erdblick/features.js new file mode 100644 index 00000000..9c3a905e --- /dev/null +++ b/static/erdblick/features.js @@ -0,0 +1,199 @@ +"use strict"; + +import {blobUriFromWasm, uint8ArrayFromWasm, uint8ArrayToWasm} from "./wasm.js"; + +/** + * Bundle of a WASM TileFeatureLayer and a rendered representation + * in the form of a Cesium 3D TileSet which references a binary GLTF tile. + * The tileset JSON and the GLTF blob are stored as browser Blob objects + * (see https://developer.mozilla.org/en-US/docs/Web/API/URL/createObjectURL_static). + * + * The WASM TileFatureLayer object is stored as a blob when not needed, + * to keep the memory usage within reasonable limits. To use the wrapped + * WASM TileFeatureLayer, use the peek()-function. + */ +export class FeatureTile +{ +// public: + /** + * Construct a FeatureTile object. + * @param coreLib Reference to the WASM erdblick library. + * @param parser Singleton TileLayerStream WASM object. + * @param tileFeatureLayer Deserialized WASM TileFeatureLayer. + */ + constructor(coreLib, parser, tileFeatureLayer) + { + this.coreLib = coreLib; + this.parser = parser; + this.id = tileFeatureLayer.id(); + this.tileId = tileFeatureLayer.tileId(); + this.children = undefined; + this.tileFeatureLayerInitDeserialized = tileFeatureLayer; + this.tileFeatureLayerSerialized = null; + this.glbUrl = null; + this.tileSetUrl = null; + this.tileSet = null; + this.disposed = false; + } + + /** + * Convert this TileFeatureLayer to a Cesium TileSet which + * contains a single tile. Returns a promise which resolves to true, + * if there is a freshly baked Cesium3DTileset, or false, + * if no output was generated because the tile is empty. + * @param {*} glbConverter The WASM GLTF converter that should be used. + * @param {null} style The style that is used to make the conversion. + */ + async render(glbConverter, style) + { + // Start timer + let startOverall = performance.now(); + + // Remove any previous render-result, as a new one is generated + // TODO: Ensure that the View also takes note of the removed Cesium3DTile. + // This will become apparent once interactive re-styling is a prime use-case. + this.disposeRenderResult(); + + let startGLBConversion = performance.now(); + let origin = null; + this.peek(tileFeatureLayer => { + this.glbUrl = blobUriFromWasm(this.coreLib, sharedBuffer => { + origin = glbConverter.render(style, tileFeatureLayer, sharedBuffer); + if (sharedBuffer.getSize() === 0) + return false; + }, "model/gltf-binary"); + }); + let endGLBConversion = performance.now(); + console.debug(`[${this.id}] GLB conversion time: ${endGLBConversion - startGLBConversion}ms`); + + // The GLB URL will be null if there were no features to render. + if (this.glbUrl === null) + return false; + + let startTilesetConversion = performance.now(); + this.tileSetUrl = blobUriFromWasm(this.coreLib, sharedBuffer => { + glbConverter.makeTileset(this.glbUrl, origin, sharedBuffer); + }, "application/json"); + let endTilesetConversion = performance.now(); + console.debug(`[${this.id}] Tileset conversion time: ${endTilesetConversion - startTilesetConversion}ms`); + + let startTilesetFromUrl = performance.now(); + this.tileSet = await Cesium.Cesium3DTileset.fromUrl(this.tileSetUrl, { + featureIdLabel: "mapgetFeatureIndex" + }) + + let endTilesetFromUrl = performance.now(); + console.debug(`[${this.id}] Cesium tileset from URL time: ${endTilesetFromUrl - startTilesetFromUrl}ms`); + + let endOverall = performance.now(); + console.debug(`[${this.id}] Overall execution time: ${endOverall - startOverall}ms`); + + return true; + } + + /** + * Deserialize the wrapped TileFeatureLayer, run a callback, then + * delete the deserialized WASM representation. + * @returns The value returned by the callback. + */ + peek(callback) { + // For the first call to peek, the tileFeatureLayer member + // is still set, and the tileFeatureLayerSerialized is not yet set. + let deserializedLayer = this.tileFeatureLayerInitDeserialized; + if (this.tileFeatureLayerInitDeserialized) { + this.tileFeatureLayerInitDeserialized = null; + this.tileFeatureLayerSerialized = uint8ArrayFromWasm(this.coreLib, bufferToWrite => { + this.parser.writeTileFeatureLayer(deserializedLayer, bufferToWrite); + }); + } + + if (!deserializedLayer) { + // Deserialize the WASM tileFeatureLayer from the blob. + console.assert(this.tileFeatureLayerSerialized); + uint8ArrayToWasm(this.coreLib, bufferToRead => { + deserializedLayer = this.parser.readTileFeatureLayer(bufferToRead); + }, this.tileFeatureLayerSerialized); + } + + // Run the callback with the deserialized layer, and + // store the result as the return value. + let result = null; + if (callback) { + result = callback(deserializedLayer); + } + + // Clean up. + deserializedLayer.delete(); + return result; + } + + /** + * Remove all data associated with a previous call to this.render(). + */ + disposeRenderResult() + { + if (!this.tileSet) + return; + if (!this.tileSet.isDestroyed) + this.tileSet.destroy(); + + this.tileSet = null; + URL.revokeObjectURL(this.tileSetUrl); + this.tileSetUrl = null; + URL.revokeObjectURL(this.glbUrl); + this.glbUrl = null; + } + + /** + * Clean up all data associated with this FeatureTile instance. + */ + dispose() + { + this.disposeRenderResult(); + if (this.tileFeatureLayerInitDeserialized) { + this.tileFeatureLayerInitDeserialized.delete(); + this.tileFeatureLayerInitDeserialized = null; + } + this.disposed = true; + console.debug(`[${this.id}] Disposed.`); + } +} + +/** + * Wrapper which combines a FeatureTile and the index of + * a feature within the tileset. Using the peek-function, it is + * possible to access the WASM feature view in a memory-safe way. + */ +export class FeatureWrapper +{ + /** + * Construct a feature wrapper from a featureTile and a feature index + * within that tile. + * @param index The index of the feature within the tile. + * @param featureTile {FeatureTile} The feature tile container. + */ + constructor(index, featureTile) { + this.index = index; + this.featureTile = featureTile; + } + + /** + * Run a callback with the WASM Feature object referenced by this wrapper. + * The feature object will be deleted after the callback is called. + * @returns The value returned by the callback. + */ + peek(callback) { + if (this.featureTile.disposed) { + throw new Error(`Unable to access feature of deleted layer ${this.featureTile.id}!`); + } + return this.featureTile.peek(tileFeatureLayer => { + let feature = tileFeatureLayer.at(this.index); + let result = null; + if (callback) { + result = callback(feature); + } + feature.delete(); + return result; + }); + } +} diff --git a/static/mapcomponent/fetch.js b/static/erdblick/fetch.js similarity index 64% rename from static/mapcomponent/fetch.js rename to static/erdblick/fetch.js index 30e701e1..c2ab50bb 100644 --- a/static/mapcomponent/fetch.js +++ b/static/erdblick/fetch.js @@ -1,3 +1,5 @@ +"use strict"; + /** * A class to fetch data from a URL and process the response * for usage in JavaScript and WebAssembly. @@ -14,10 +16,12 @@ export class Fetch this.url = url; this.method = 'GET'; this.body = null; - this.signal = null; + this.abortController = new AbortController(); this.processChunks = false; this.jsonCallback = null; this.wasmCallback = null; + this.wasmBufferDeletedByUser = false; + this.aborted = false; } /** @@ -40,16 +44,6 @@ export class Fetch return this; } - /** - * Method to set the signal for the request (for aborting the request). - * @param {AbortSignal} signal - The AbortSignal object. - * @return {Fetch} The Fetch instance for chaining. - */ - withSignal(signal) { - this.signal = signal; - return this; - } - /** * Method to enable chunk processing for the response. * @return {Fetch} The Fetch instance for chaining. @@ -72,10 +66,15 @@ export class Fetch /** * Method to set the callback for handling the WASM response. * @param {Function} callback - The callback function. + * @param {boolean} deletionByUser - Whether the passed WASM shared + * buffer should not be immediately deleted after the callback is + * called - this is used to in the streaming viewport response + * handling to ensure that the fetch operation is not stalled. * @return {Fetch} The Fetch instance for chaining. */ - withWasmCallback(callback) { + withWasmCallback(callback, deletionByUser) { this.wasmCallback = callback; + this.wasmBufferDeletedByUser = deletionByUser; return this; } @@ -90,7 +89,7 @@ export class Fetch // Currently, the connection stays open for five seconds. 'Connection': 'close' }, - signal: this.signal, + signal: this.abortController.signal, keepalive: false, mode: "same-origin" }; @@ -109,7 +108,7 @@ export class Fetch console.assert(!this.processChunks) this.handleJsonResponse(response); } else if (this.processChunks) { - this.handleChunkedResponse(response); + this.handleChunkedResponse(response).then(_ => {}).catch(); } else { this.handleBlobResponse(response); } @@ -131,22 +130,47 @@ export class Fetch /** * Method to handle and process a chunked response. + * The chunks must be encoded as Version-Type-Length-Value (VTLV) frames, + * where Version is 6B, Type is 1B, Length is 4B (Little Endian). + * This is the chunk encoding used by the mapget TileLayerStream. * @param {Response} response - The fetch response. */ - handleChunkedResponse(response) { + async handleChunkedResponse(response) { const reader = response.body.getReader(); - let pump = () => { - return reader.read().then(({ done, value }) => { - if (value) { - let uint8Array = new Uint8Array(value.buffer); - this.runWasmCallback(uint8Array); + let accumulatedData = new Uint8Array(0); + + const processAccumulatedData = () => { + while (accumulatedData.length >= 11) { // Ensure we have at least VTL header + const type = accumulatedData[6]; + const length = new DataView(accumulatedData.buffer, 7, 4).getUint32(0, true); // Little-endian + + // Check if we have the full VTLV frame + if (accumulatedData.length >= 6 + 1 + 4 + length) { + const vtlvFrame = accumulatedData.slice(0, 11 + length); + this.runWasmCallback(vtlvFrame); + + // Remove the processed data from the beginning of accumulatedData + accumulatedData = accumulatedData.slice(11 + length); + } else { + break; } - if (done) - return; - return pump(); - }); + } + } + + while (true) { + const { done, value } = await reader.read(); + if (value && value.length) { + // Append new data to accumulatedData + const temp = new Uint8Array(accumulatedData.length + value.length); + temp.set(accumulatedData); + temp.set(value, accumulatedData.length); + accumulatedData = temp; + + // Try to process any complete VTLV frames + processAccumulatedData(); + } + if (done) break; } - pump(); } /** @@ -188,7 +212,7 @@ export class Fetch */ runWasmCallback(uint8Array) { - if (!this.wasmCallback) + if (!this.wasmCallback || this.aborted) return; let sharedArr = new this.coreLib.SharedUint8Array(uint8Array.length); @@ -202,6 +226,24 @@ export class Fetch this.wasmCallback(sharedArr); } - sharedArr.delete(); + if (!this.wasmBufferDeletedByUser) { + sharedArr.delete(); + } + } + + /** + * Signal that the request should be aborted. + */ + abort() { + if (this.aborted) + return + try { + // For some reason, abort always throws an exception by design. + this.abortController.abort("User abort."); + } + catch (e) { + // Nothing to do. + } + this.aborted = true; } } diff --git a/static/erdblick/model.js b/static/erdblick/model.js new file mode 100644 index 00000000..7b986cf3 --- /dev/null +++ b/static/erdblick/model.js @@ -0,0 +1,222 @@ +"use strict"; + +import {Fetch} from "./fetch.js"; +import {FeatureTile} from "./features.js"; + +const styleUrl = "/styles/demo-style.yaml"; +const infoUrl = "/sources"; +const tileUrl = "/tiles"; + +/** + * Erdblick view-model class. This class is responsible for keeping track + * of the following objects: + * (1) available maps + * (2) currently loaded tiles + * (3) available style sheets. + * + * As the viewport changes, it requests new tiles from the mapget server + * and triggers their conversion to Cesium tiles according to the active + * style sheets. + */ +export class ErdblickModel +{ + constructor(coreLibrary) + { + this.coreLib = coreLibrary; + this.style = null; + this.maps = null; + this.glbConverter = new coreLibrary.FeatureLayerRenderer(); + this.loadedTileLayers = new Map(); + this.currentFetch = null; + this.currentFetchId = 0; + this.currentViewport = { + south: .0, + west: .0, + width: .0, + height: .0, + camPosLon: .0, + camPosLat: .0, + orientation: .0, + }; + this.currentVisibleTileIds = new Set(); + + // Instantiate the TileLayerParser, and set its callback + // for when a new tile is received. + this.tileParser = new this.coreLib.TileLayerParser(); + this.tileParser.onTileParsedFromStream(tileFeatureLayer => { + const isInViewport = this.currentVisibleTileIds.has(tileFeatureLayer.tileId()); + const alreadyLoaded = this.loadedTileLayers.has(tileFeatureLayer.id()); + if (isInViewport && !alreadyLoaded) { + let tile = new FeatureTile(this.coreLib, this.tileParser, tileFeatureLayer); + this.addTileLayer(tile); + } + else + tileFeatureLayer.delete(); + }); + + /////////////////////////////////////////////////////////////////////////// + // MODEL EVENTS // + /////////////////////////////////////////////////////////////////////////// + + /// Triggered when a tile layer is freshly rendered and should be added to the frontend. + this.tileLayerAddedTopic = new rxjs.Subject(); // {FeatureTile} + + /// Triggered when a tile layer is being removed. + this.tileLayerRemovedTopic = new rxjs.Subject(); // {FeatureTile} + + /// Triggered when the user requests to zoom to a map layer. + this.zoomToWgs84PositionTopic = new rxjs.Subject(); // {.x,.y} + + /// Triggered when the map info is updated. + this.mapInfoTopic = new rxjs.Subject(); // {: } + + /////////////////////////////////////////////////////////////////////////// + // BOOTSTRAP // + /////////////////////////////////////////////////////////////////////////// + + this.reloadStyle(); + this.reloadDataSources(); + } + + reloadStyle() + { + // Delete the old style if present. + if (this.style) + this.style.delete(); + + // Fetch the new one. + new Fetch(this.coreLib, styleUrl).withWasmCallback(styleYamlBuffer => { + // Parse the style description into a WASM style object. + this.style = new this.coreLib.FeatureLayerStyle(styleYamlBuffer); + + // Re-render all present batches with the new style. + for (let [batchId, batch] of this.loadedTileLayers.entries()) { + this.renderTileLayer(batch, true); + } + console.log("Loaded style."); + }).go(); + } + + reloadDataSources() { + new Fetch(this.coreLib, infoUrl) + .withWasmCallback(infoBuffer => { + this.tileParser.setDataSourceInfo(infoBuffer); + console.log("Loaded data source info."); + }) + .withJsonCallback(result => { + this.maps = Object.fromEntries(result.map(mapInfo => [mapInfo.mapId, mapInfo])); + this.mapInfoTopic.next(this.maps); + }) + .go(); + } + + /////////////////////////////////////////////////////////////////////////// + // MAP UPDATE CONTROLS // + /////////////////////////////////////////////////////////////////////////// + + update() + { + // Get the tile IDs for the current viewport. + const allViewportTileIds = this.coreLib.getTileIds(this.currentViewport, 13, 512); + this.currentVisibleTileIds = new Set(allViewportTileIds); + + // Abort previous fetch operation. + if (this.currentFetch) + this.currentFetch.abort(); + + // Make sure that there are no unparsed bytes lingering from the previous response stream. + this.tileParser.reset(); + + // Evict present non-required tile layers. + let newTileLayers = new Map(); + for (let tileLayer of this.loadedTileLayers.values()) { + if (!this.currentVisibleTileIds.has(tileLayer.tileId)) { + this.tileLayerRemovedTopic.next(tileLayer); + tileLayer.dispose(); + } + else + newTileLayers.set(tileLayer.id, tileLayer); + } + this.loadedTileLayers = newTileLayers; + + // Request non-present required tile layers. + let requests = [] + for (let [mapName, map] of Object.entries(this.maps)) { + for (let [layerName, layer] of Object.entries(map.layers)) + { + // Find tile IDs which are not yet loaded for this map layer combination. + let requestTilesForMapLayer = [] + for (let tileId of allViewportTileIds) { + const tileMapLayerKey = this.coreLib.getTileFeatureLayerKey(mapName, layerName, tileId); + if (!this.loadedTileLayers.has(tileMapLayerKey)) + requestTilesForMapLayer.push(Number(tileId)); + } + + // Only add a request if there are tiles to be loaded. + if (requestTilesForMapLayer) + requests.push({ + mapId: mapName, + layerId: layerName, + tileIds: requestTilesForMapLayer + }); + } + } + + let fetchId = ++(this.currentFetchId); + this.currentFetch = new Fetch(this.coreLib, tileUrl) + .withChunkProcessing() + .withMethod("POST") + .withBody({ + requests: requests, + maxKnownFieldIds: this.tileParser.fieldDictOffsets() + }) + .withWasmCallback(tileBuffer => { + // Schedule the parsing of the newly arrived tile layer, + // but don't do it synchronously to avoid stalling the ongoing + // fetch operation. + setTimeout(_ => { + // Only process the buffer chunk, if the fetch operation + // for the chunk is the most recent one. + if (fetchId === this.currentFetchId) { + this.tileParser.parseFromStream(tileBuffer); + } + tileBuffer.delete(); + }, 0) + }, true); + this.currentFetch.go(); + } + + addTileLayer(tileLayer) { + if (this.loadedTileLayers.has(tileLayer.id)) { + throw new Error(`Refusing to add tile layer ${tileLayer.id}, which is already present.`); + } + this.loadedTileLayers.set(tileLayer.id, tileLayer); + this.renderTileLayer(tileLayer); + } + + renderTileLayer(tileLayer, removeFirst) { + if (removeFirst) { + this.tileLayerRemovedTopic.next(tileLayer); + } + tileLayer.render(this.glbConverter, this.style).then(wasRendered => { + if (!wasRendered) + return; + + // It is possible, that the tile went out of view while + // Cesium took its time to load it. In this case, don't + // add it to the viewport. + const isInViewport = this.currentVisibleTileIds.has(tileLayer.tileId); + if (isInViewport) + this.tileLayerAddedTopic.next(tileLayer); + else + tileLayer.disposeRenderResult(); + }) + } + +// public: + + setViewport(viewport) { + this.currentViewport = viewport; + this.update(); + } +} diff --git a/static/mapcomponent/timer.js b/static/erdblick/timer.js similarity index 97% rename from static/mapcomponent/timer.js rename to static/erdblick/timer.js index d534a099..8812b562 100644 --- a/static/mapcomponent/timer.js +++ b/static/erdblick/timer.js @@ -1,3 +1,5 @@ +"use strict"; + export class SingleShotTimer { constructor(interval, callback, waitForRestart) { diff --git a/static/erdblick/view.js b/static/erdblick/view.js new file mode 100644 index 00000000..6168b0f8 --- /dev/null +++ b/static/erdblick/view.js @@ -0,0 +1,257 @@ +"use strict"; + +import {ErdblickModel} from "./model.js"; +import {FeatureWrapper} from "./features.js"; + +export class ErdblickView +{ + /** + * Construct a Cesium View with a Model. + * @param {ErdblickModel} model + * @param containerDomElementId Div which hosts the Cesium view. + */ + constructor(model, containerDomElementId) + { + this.model = model; + this.viewer = new Cesium.Viewer(containerDomElementId, + { + imageryProvider: false, + baseLayerPicker: false, + animation: false, + geocoder: false, + homeButton: false, + sceneModePicker: false, + selectionIndicator: false, + timeline: false, + navigationHelpButton: false, + navigationInstructionsInitiallyVisible: false, + requestRenderMode: true, + maximumRenderTimeChange: Infinity, + infoBox: false + } + ); + + let openStreetMap = new Cesium.UrlTemplateImageryProvider({ + url: 'https://a.tile.openstreetmap.org/{z}/{x}/{y}.png', + maximumLevel: 19, + }); + let openStreetMapLayer = this.viewer.imageryLayers.addImageryProvider(openStreetMap); + openStreetMapLayer.alpha = 0.3; + + this.pickedFeature = null; + this.hoveredFeature = null; + this.mouseHandler = new Cesium.ScreenSpaceEventHandler(this.viewer.scene.canvas); + + // Holds the currently selected feature. + this.selectionTopic = new rxjs.BehaviorSubject(null); // {FeatureWrapper} + + // Add a handler for selection. + this.mouseHandler.setInputAction(movement => { + // If there was a previously picked feature, reset its color. + if (this.pickedFeature) { + // TODO: Feature color must be reset to its original color, + // based on the style sheet. + this.pickedFeature.color = Cesium.Color.WHITE; + } + + let feature = this.viewer.scene.pick(movement.position); + + if (feature instanceof Cesium.Cesium3DTileFeature) { + feature.color = Cesium.Color.YELLOW; + this.pickedFeature = feature; // Store the picked feature. + this.hoveredFeature = null; + this.selectionTopic.next(this.resolveFeature(feature.tileset, feature.featureId)) + } + else + this.selectionTopic.next(null); + }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + + // Add a handler for hover (i.e., MOUSE_MOVE) functionality. + this.mouseHandler.setInputAction(movement => { + // If there was a previously hovered feature, reset its color. + if (this.hoveredFeature) { + this.hoveredFeature.color = Cesium.Color.WHITE; // Assuming the original color is WHITE. Adjust as necessary. + } + + let feature = this.viewer.scene.pick(movement.endPosition); // Notice that for MOUSE_MOVE, it's endPosition + + if (feature instanceof Cesium.Cesium3DTileFeature) { + if (feature !== this.pickedFeature) { + feature.color = Cesium.Color.GREEN; + this.hoveredFeature = feature; // Store the hovered feature. + } + } + }, Cesium.ScreenSpaceEventType.MOUSE_MOVE); + + // Add a handler for camera movement. + this.viewer.camera.percentageChanged = 0.1; + this.viewer.camera.changed.addEventListener(() => { + this.updateViewport(); + }); + + this.tileLayerForTileSet = new Map(); + + model.tileLayerAddedTopic.subscribe(tileLayer => { + this.viewer.scene.primitives.add(tileLayer.tileSet); + this.tileLayerForTileSet.set(tileLayer.tileSet, tileLayer); + }) + + model.tileLayerRemovedTopic.subscribe(tileLayer => { + if (this.pickedFeature && this.pickedFeature.tileset === tileLayer.tileSet) { + this.pickedFeature = null; + this.selectionTopic.next(null); + } + if (this.hoveredFeature && this.hoveredFeature.tileset === tileLayer.tileSet) { + this.hoveredFeature = null; + } + this.viewer.scene.primitives.remove(tileLayer.tileSet); + this.tileLayerForTileSet.delete(tileLayer.tileSet); + }) + + model.zoomToWgs84PositionTopic.subscribe(pos => { + this.viewer.camera.setView({ + destination: Cesium.Cartesian3.fromDegrees(pos.x, pos.y, 15000), // Converts lon/lat to Cartesian3. + orientation: { + heading: Cesium.Math.toRadians(0), // East, in radians. + pitch: Cesium.Math.toRadians(-90), // Directly looking down. + roll: 0 // No rotation + } + }); + }); + + let polylines = new Cesium.PolylineCollection(); + + // Line over the equator divided into four 90-degree segments. + this.viewer.entities.add({ + name: 'Equator', + polyline: { + positions: Cesium.Cartesian3.fromDegreesArray([ + -180, 0, // Start + -90, 0, // 1st quarter + 0, 0, // Halfway + 90, 0, // 3rd quarter + 180, 0 // End + ]), + width: 2, + material: Cesium.Color.RED.withAlpha(0.5) + } + }); + + // Line over the antimeridian. + this.viewer.entities.add({ + name: 'Antimeridian', + polyline: { + positions: Cesium.Cartesian3.fromDegreesArray([ + -180, -80, + -180, 80 + ]), + width: 2, + material: Cesium.Color.BLUE.withAlpha(0.5) + } + }); + + this.viewer.scene.primitives.add(polylines); + this.viewer.scene.globe.baseColor = new Cesium.Color(0.1, 0.1, 0.1, 1); + } + + resolveFeature(tileSet, index) { + let tileLayer = this.tileLayerForTileSet.get(tileSet); + if (!tileLayer) { + console.error("Failed find tileLayer for tileSet!"); + return null; + } + return new FeatureWrapper(index, tileLayer); + } + + updateViewport() { + let canvas = this.viewer.scene.canvas; + let center = new Cesium.Cartesian2(canvas.clientWidth / 2, canvas.clientHeight / 2); + let centerCartesian = this.viewer.camera.pickEllipsoid(center); + let centerLon, centerLat; + + if (Cesium.defined(centerCartesian)) { + let centerCartographic = Cesium.Cartographic.fromCartesian(centerCartesian); + centerLon = Cesium.Math.toDegrees(centerCartographic.longitude); + centerLat = Cesium.Math.toDegrees(centerCartographic.latitude); + } else { + let cameraCartographic = Cesium.Cartographic.fromCartesian(this.viewer.camera.positionWC); + centerLon = Cesium.Math.toDegrees(cameraCartographic.longitude); + centerLat = Cesium.Math.toDegrees(cameraCartographic.latitude); + } + + let rectangle = this.viewer.camera.computeViewRectangle(); + + let west = Cesium.Math.toDegrees(rectangle.west); + let south = Cesium.Math.toDegrees(rectangle.south); + let east = Cesium.Math.toDegrees(rectangle.east); + let north = Cesium.Math.toDegrees(rectangle.north); + let sizeLon = east - west; + let sizeLat = north - south; + + // Handle the antimeridian. + // TODO: Must also handle north pole. + if (west > -180 && sizeLon > 180.) { + sizeLon = 360. - sizeLon; + } + + // Grow the viewport rectangle by 25% + let expandLon = sizeLon * 0.25; + let expandLat = sizeLat * 0.25; + this.model.setViewport({ + south: south - expandLat, + west: west - expandLon, + width: sizeLon + expandLon*2, + height: sizeLat + expandLat*2, + camPosLon: centerLon, + camPosLat: centerLat, + orientation: this.viewer.camera.heading, + }); + this.visualizeTileIds(); + } + + visualizeTileIds() { + // Remove previous points. + if (this.points) { + for (let i = 0; i < this.points.length; i++) { + this.viewer.entities.remove(this.points[i]); + } + } + + // Get the tile IDs for the current viewport. + let tileIds = this.model.currentVisibleTileIds; + + // Calculate total number of tile IDs. + let totalTileIds = tileIds.size; + + // Initialize points array. + this.points = []; + + // Counter for iteration over Set. + let i = 0; + + // Iterate through each tile ID using Set's forEach method. + tileIds.forEach(tileId => { + // Get WGS84 coordinates for the tile ID + let position = this.model.coreLib.getTilePosition(tileId); + + // Calculate the color based on the position in the list. + let colorValue = i / totalTileIds; + let color = Cesium.Color.fromHsl(0.6 - colorValue * 0.5, 1.0, 0.5); + + // Create a point and add it to the Cesium scene. + let point = this.viewer.entities.add({ + position: Cesium.Cartesian3.fromDegrees(position.x, position.y), + point: { + pixelSize: 5, + color: color + } + }); + + // Add the point to the points array. + this.points.push(point); + + // Increment counter. + i++; + }); + } +} diff --git a/static/erdblick/wasm.js b/static/erdblick/wasm.js new file mode 100644 index 00000000..94dcfa50 --- /dev/null +++ b/static/erdblick/wasm.js @@ -0,0 +1,53 @@ +"use strict"; + +/** + * Run a WASM function which places data in a SharedUint8Array, + * and then store this data under an object URL. Will be aborted + * and return null, if the user function returns false. + */ +export function blobUriFromWasm(coreLib, fun, contentType) { + let sharedGlbArray = new coreLib.SharedUint8Array(); + if (fun(sharedGlbArray) === false) { + sharedGlbArray.delete(); + return null; + } + let objSize = sharedGlbArray.getSize(); + let bufferPtr = Number(sharedGlbArray.getPointer()); + let data = coreLib.HEAPU8.buffer.slice(bufferPtr, bufferPtr + objSize); + const blob = new Blob([data], { type: contentType }); + const glbUrl = URL.createObjectURL(blob); + sharedGlbArray.delete(); + return glbUrl; +} + +/** + * Run a WASM function which places data in a SharedUint8Array, + * and then retrieve this data as a Uint8Array. Will return null + * if the user function returns false. + */ +export function uint8ArrayFromWasm(coreLib, fun) { + let sharedGlbArray = new coreLib.SharedUint8Array(); + if (fun(sharedGlbArray) === false) { + sharedGlbArray.delete(); + return null; + } + let objSize = sharedGlbArray.getSize(); + let bufferPtr = Number(sharedGlbArray.getPointer()); + let data = new Uint8Array(coreLib.HEAPU8.buffer.slice(bufferPtr, bufferPtr + objSize)); + sharedGlbArray.delete(); + return data; +} + +/** + * Copy the contents of a given Uint8Array to a WASM function + * through a SharedUint8Array. If the operation fails or the WASM function + * returns false, null is returned. + */ +export function uint8ArrayToWasm(coreLib, fun, inputData) { + let sharedGlbArray = new coreLib.SharedUint8Array(inputData.length); + let bufferPtr = Number(sharedGlbArray.getPointer()); + coreLib.HEAPU8.set(inputData, bufferPtr); + let result = fun(sharedGlbArray); + sharedGlbArray.delete(); + return (result === false) ? null : result; +} diff --git a/static/index.css b/static/index.css index 91d790de..0e663f07 100644 --- a/static/index.css +++ b/static/index.css @@ -19,7 +19,7 @@ body { -o-user-select:none; } -#cesiumContainer .cesium-widget-credits { +#mapViewContainer .cesium-widget-credits { display:none !important; } @@ -90,11 +90,16 @@ body { left: 10px; } -#info > #log { +#info > #maps { display: none; } -#info.expanded > #log { +#info > #maps > div { + margin-top: 0.5em; + margin-bottom: 0; +} + +#info.expanded > #maps { display: block; } diff --git a/static/index.html b/static/index.html index 4dbc548d..a71b0b59 100644 --- a/static/index.html +++ b/static/index.html @@ -15,11 +15,10 @@ - - - - - + + + + @@ -30,14 +29,13 @@ -
+
erdblick v0.2.0 // - //
-
+
diff --git a/static/index.js b/static/index.js index f2a72bcd..afcd6ac3 100644 --- a/static/index.js +++ b/static/index.js @@ -1,5 +1,5 @@ -import { MapViewerView } from "./mapcomponent/view.js"; -import { MapViewerModel } from "./mapcomponent/model.js"; +import { ErdblickView } from "./erdblick/view.js"; +import { ErdblickModel } from "./erdblick/model.js"; import libErdblickCore from "./libs/core/erdblick-core.js"; // --------------------------- Initialize Map Componesnt -------------------------- @@ -9,22 +9,13 @@ libErdblickCore().then(coreLib => { console.log(" ...done.") - let mapModel = new MapViewerModel(coreLib); - let mapView = new MapViewerView(mapModel, 'cesiumContainer'); - - window.loadAllTiles = () => { - $("#log").empty() - mapModel.runUpdate(); - } + let mapModel = new ErdblickModel(coreLib); + let mapView = new ErdblickView(mapModel, 'mapViewContainer'); window.reloadStyle = () => { mapModel.reloadStyle(); } - window.zoomToBatch = (batchId) => { - mapView.viewer.zoomTo(mapModel.registeredBatches.get(batchId).tileSet); - } - mapView.selectionTopic.subscribe(selectedFeatureWrapper => { if (!selectedFeatureWrapper) { $("#selectionPanel").hide() @@ -37,6 +28,23 @@ libErdblickCore().then(coreLib => $("#selectionPanel").show() }) }) + + mapModel.mapInfoTopic.subscribe(mapInfo => { + let mapSettingsBox = $("#maps"); + mapSettingsBox.empty() + for (let [mapName, map] of Object.entries(mapInfo)) { + for (let [layerName, layer] of Object.entries(map.layers)) { + let mapsEntry = $(`
${mapName} / ${layerName} 
`); + $(mapsEntry.find("button")).on("click", _=>{ + // Grab first tile id from coverage and zoom to it. + // TODO: Zoom to extent of map instead. + if (layer.coverage[0] !== undefined) + mapModel.zoomToWgs84PositionTopic.next(coreLib.getTilePosition(BigInt(layer.coverage[0]))); + }) + mapSettingsBox.append(mapsEntry) + } + } + }) }) $(document).ready(function() { diff --git a/static/mapcomponent/featurelayer.js b/static/mapcomponent/featurelayer.js deleted file mode 100644 index eb3e0c1c..00000000 --- a/static/mapcomponent/featurelayer.js +++ /dev/null @@ -1,109 +0,0 @@ -"use strict"; - -/** - * Run a WASM function which places data in a SharedUint8Array, - * and then store this data under an object URL. - */ -function blobUriFromWasm(coreLib, fun, contentType) { - let sharedGlbArray = new coreLib.SharedUint8Array(); - fun(sharedGlbArray); - let objSize = sharedGlbArray.getSize(); - let bufferPtr = Number(sharedGlbArray.getPointer()); - let data = coreLib.HEAPU8.buffer.slice(bufferPtr, bufferPtr + objSize); - const blob = new Blob([data], { type: contentType }); - const glbUrl = URL.createObjectURL(blob); - sharedGlbArray.delete(); - return glbUrl; -} - -/** - * Bundle of a WASM TileFeatureLayer and a rendered representation - * in the form of a Cesium 3D TileSet which references a binary GLTF tile. - * The tileset JSON and the GLTF blob are stored as browser Blob objects - * (see https://developer.mozilla.org/en-US/docs/Web/API/URL/createObjectURL_static). - */ -export class FeatureLayerTileSet -{ -// public: - constructor(batchName, tileFeatureLayer) - { - this.id = batchName; - this.children = undefined; - this.tileFeatureLayer = tileFeatureLayer; - this.glbUrl = null; - this.tileSetUrl = null; - this.tileSet = null; - } - - /** - * Convert this batch's tile to GLTF and broadcast the result - */ - render(coreLib, glbConverter, style, onResult) - { - this.disposeRenderResult(); - - let origin = null; - this.glbUrl = blobUriFromWasm(coreLib, sharedBuffer => { - origin = glbConverter.render(style, this.tileFeatureLayer, sharedBuffer); - }, "model/gltf-binary"); - - this.tileSetUrl = blobUriFromWasm(coreLib, sharedBuffer => { - glbConverter.makeTileset(this.glbUrl, origin, sharedBuffer); - }, "application/json"); - - Cesium.Cesium3DTileset.fromUrl(this.tileSetUrl, { - featureIdLabel: "mapgetFeatureIndex" - }).then(tileSet => { - this.tileSet = tileSet; - onResult(this); - }); - } - - disposeRenderResult() - { - if (!this.tileSet) - return; - - this.tileSet.destroy(); - this.tileSet = null; - URL.revokeObjectURL(this.tileSetUrl); - this.tileSetUrl = null; - URL.revokeObjectURL(this.glbUrl); - this.glbUrl = null; - } - - dispose() - { - this.disposeRenderResult(); - this.tileFeatureLayer.delete(); - this.tileFeatureLayer = null; - } -} - -/** - * Wrapper which combines a FeatureLayerTileSet and the index of - * a feature within the tileset. Using the peek-function, it is - * possible to access the WASM feature view in a memory-safe way. - */ -export class FeatureWrapper -{ - constructor(index, featureLayerTileSet) { - this.index = index; - this.featureLayerTileSet = featureLayerTileSet; - } - - /** - * Run a callback with the WASM Feature object referenced by this wrapper. - * The feature object will be deleted after the callback is called. - */ - peek(callback) { - if (!this.featureLayerTileSet.tileFeatureLayer) { - throw new RuntimeError("Unable to access feature of deleted layer."); - } - let feature = this.featureLayerTileSet.tileFeatureLayer.at(this.index); - if (callback) { - callback(feature); - } - feature.delete(); - } -} diff --git a/static/mapcomponent/model.js b/static/mapcomponent/model.js deleted file mode 100644 index b1194fd7..00000000 --- a/static/mapcomponent/model.js +++ /dev/null @@ -1,156 +0,0 @@ -"use strict"; - -import {throttle} from "./utils.js"; -import {Fetch} from "./fetch.js"; -import {FeatureLayerTileSet} from "./featurelayer.js"; - -const minViewportChangedCallDelta = 200; // ms - -const styleUrl = "/styles/demo-style.yaml"; -const infoUrl = "/sources"; -const tileUrl = "/tiles"; - - -export class MapViewerModel -{ - constructor(coreLibrary) - { - this.coreLib = coreLibrary; - - this.style = null; - this.sources = null; - this.glbConverter = new coreLibrary.FeatureLayerRenderer(); - - this.registeredBatches = new Map(); - - this.update = { - running: false, - numLoadingBatches: 0, - loadingBatchNames: new Set(), - fetch: null, - stream: null - }; - - this._viewportUpdateThrottle = throttle( - minViewportChangedCallDelta, - (viewport, jumped, camPos, alt, tilt, orientation) => - { - this.update.viewport = viewport.clone(); - // this.update() - } - ); - - /////////////////////////////////////////////////////////////////////////// - // MODEL EVENTS // - /////////////////////////////////////////////////////////////////////////// - - /// Triggered upon GLB load finished, with the visual and picking geometry batch roots. - /// Received by frontend and MapViewerRenderingController. - this.batchAddedTopic = new rxjs.Subject(); // {MapViewerBatch} - - /// Triggered upon onBatchRemoved with the visual and picking geometry batch roots. - /// Received by frontend and MapViewerRenderingController. - this.batchRemovedTopic = new rxjs.Subject(); // {MapViewerBatch} - - /////////////////////////////////////////////////////////////////////////// - // BOOTSTRAP // - /////////////////////////////////////////////////////////////////////////// - - this.reloadStyle() - this.reloadSources() - } - - reloadStyle() { - if (this.style) - this.style.delete() - new Fetch(this.coreLib, styleUrl).withWasmCallback(styleYamlBuffer => { - this.style = new this.coreLib.FeatureLayerStyle(styleYamlBuffer); - for (let [batchId, batch] of this.registeredBatches.entries()) { - this.renderBatch(batch, true) - } - console.log("Loaded style.") - }).go(); - } - - reloadSources() { - new Fetch(this.coreLib, infoUrl) - .withWasmCallback(infoBuffer => { - if (this.update.stream) - this.update.stream.delete() - this.update.stream = new this.coreLib.TileLayerParser(infoBuffer); - this.update.stream.onTileParsed(tile => { - this.addBatch(tile) - $("#log").append(`Loaded ${tile.id()} 
`) - }); - console.log("Loaded data source info.") - }) - .withJsonCallback(result => {this.sources = result;}) - .go(); - } - - /////////////////////////////////////////////////////////////////////////// - // MAP UPDATE CONTROLS // - /////////////////////////////////////////////////////////////////////////// - - runUpdate() { - // TODO - // if (this.update.fetch) - // this.update.fetch.abort() - // if (this.update.stream) - // this.update.stream.clear() - - // TODO: Remove present batches - - let requests = [] - for (let dataSource of this.sources) { - for (let [layerName, layer] of Object.entries(dataSource.layers)) { - requests.push({ - mapId: dataSource.mapId, - layerId: layerName, - tileIds: layer.coverage - }) - } - } - - new Fetch(this.coreLib, tileUrl) - .withChunkProcessing() - .withMethod("POST") - // TODO: Add fields dict offset info to request - .withBody({requests: requests}) - .withWasmCallback(tileBuffer => { - this.update.stream.parse(tileBuffer); - }) - .go(); - } - - addBatch(tile) { - let batchName = tile.id(); - let batch = new FeatureLayerTileSet(batchName, tile) - this.registeredBatches.set(batchName, batch) - this.renderBatch(batch); - } - - renderBatch(batch, removeFirst) { - if (removeFirst) { - this.batchRemovedTopic.next(batch) - } - batch.render(this.coreLib, this.glbConverter, this.style, batch => { - this.batchAddedTopic.next(batch) - }) - } - - removeBatch(batchName) { - this.batchRemovedTopic.next(this.registeredBatches.get(batchName)); - this.registeredBatches.delete(batchName); - } - -// public: - - viewportChanged(viewport, jumped, camPos, alt, tilt, orientation) { - this._viewportUpdateThrottle(viewport, jumped, camPos, alt, tilt, orientation); - } - - go() { - // TODO: Implement Initial Data Request - } -} diff --git a/static/mapcomponent/utils.js b/static/mapcomponent/utils.js deleted file mode 100644 index 58f97201..00000000 --- a/static/mapcomponent/utils.js +++ /dev/null @@ -1,40 +0,0 @@ -"use strict"; - -import {SingleShotTimer} from "./timer.js"; - -/** - * Throttle calls to a function to always have at least a specific interval inbetween. - */ -export function throttle(minIv, fn) -{ - let lastExecTimestamp = new Date(0); - let lastArgs = []; - let finalCallTimer = new SingleShotTimer(minIv, ()=>fn(...lastArgs), true); - - return (...args) => - { - lastArgs = args; - let currentTime = new Date(); - if (currentTime - lastExecTimestamp < minIv) { - finalCallTimer.restart(); - return; - } - finalCallTimer.stop(); - lastExecTimestamp = currentTime; - fn(...args); - }; -} - -/** - * Option the first value of a cookie with a specific name. - */ -export function cookieValue(cookieName) { - for (let cookie of document.cookie.split(';')) { - let keyValuePair = cookie.trim().split("="); - if (keyValuePair.length < 2) - continue; - if (keyValuePair[0].trim() === cookieName) - return keyValuePair[1].trim(); - } - return null; -} diff --git a/static/mapcomponent/view.js b/static/mapcomponent/view.js deleted file mode 100644 index 3ab09b5b..00000000 --- a/static/mapcomponent/view.js +++ /dev/null @@ -1,111 +0,0 @@ -import {MapViewerModel} from "./model.js"; -import {FeatureWrapper} from "./featurelayer.js"; - -export class MapViewerView -{ - /** - * Construct a Cesium View with a Model. - * @param {MapViewerModel} model - * @param containerDomElementId Div which hosts the Cesium view. - */ - constructor(model, containerDomElementId) - { - // The base64 encoding of a 1x1 black PNG. - let blackPixelBase64 = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGNgYGAAAAAEAAH2FzhVAAAAAElFTkSuQmCC'; - - this.viewer = new Cesium.Viewer(containerDomElementId, - { - // Create a SingleTileImageryProvider that uses the black pixel. - imageryProvider: new Cesium.SingleTileImageryProvider({ - url: blackPixelBase64, - rectangle: Cesium.Rectangle.MAX_VALUE, - tileWidth: 1, - tileHeight: 1 - }), - baseLayerPicker: false, - animation: false, - geocoder: false, - homeButton: false, - sceneModePicker: false, - selectionIndicator: false, - timeline: false, - navigationHelpButton: false, - navigationInstructionsInitiallyVisible: false, - requestRenderMode: true, - maximumRenderTimeChange: Infinity, - infoBox: false - } - ); - - // let openStreetMap = new Cesium.UrlTemplateImageryProvider({ - // url: 'https://a.tile.openstreetmap.org/{z}/{x}/{y}.png', - // maximumLevel: 19, - // }); - // let openStreetMapLayer = this.viewer.imageryLayers.addImageryProvider(openStreetMap); - // openStreetMapLayer.alpha = 0.5; - - this.pickedFeature = null; - this.hoveredFeature = null; - this.mouseHandler = new Cesium.ScreenSpaceEventHandler(this.viewer.scene.canvas); - - /// Holds the currently selected feature. - this.selectionTopic = new rxjs.BehaviorSubject(null); // {FeatureWrapper} - - // Add a handler for selection. - this.mouseHandler.setInputAction(movement => { - // If there was a previously picked feature, reset its color. - if (this.pickedFeature) { - this.pickedFeature.color = Cesium.Color.WHITE; // Assuming the original color is WHITE. Adjust as necessary. - } - - let feature = this.viewer.scene.pick(movement.position); - - if (feature instanceof Cesium.Cesium3DTileFeature) { - feature.color = Cesium.Color.YELLOW; - this.pickedFeature = feature; // Store the picked feature. - this.hoveredFeature = null; - this.selectionTopic.next(this.resolveFeature(feature.tileset, feature.featureId)) - } - else - this.selectionTopic.next(null); - }, Cesium.ScreenSpaceEventType.LEFT_CLICK); - - // Add a handler for hover (i.e., MOUSE_MOVE) functionality. - this.mouseHandler.setInputAction(movement => { - // If there was a previously hovered feature, reset its color. - if (this.hoveredFeature) { - this.hoveredFeature.color = Cesium.Color.WHITE; // Assuming the original color is WHITE. Adjust as necessary. - } - - let feature = this.viewer.scene.pick(movement.endPosition); // Notice that for MOUSE_MOVE, it's endPosition - - if (feature instanceof Cesium.Cesium3DTileFeature) { - if (feature !== this.pickedFeature) { - feature.color = Cesium.Color.GREEN; - this.hoveredFeature = feature; // Store the hovered feature. - } - } - }, Cesium.ScreenSpaceEventType.MOUSE_MOVE); - - this.batchForTileSet = new Map(); - - model.batchAddedTopic.subscribe(batch => { - this.viewer.scene.primitives.add(batch.tileSet); - this.batchForTileSet.set(batch.tileSet, batch); - }) - - model.batchRemovedTopic.subscribe(batch => { - this.viewer.scene.primitives.remove(batch.tileSet); - this.batchForTileSet.delete(batch.tileSet); - }) - } - - resolveFeature(tileSet, index) { - let batch = this.batchForTileSet.get(tileSet); - if (!batch) { - console.error("Failed find batch for tileSet!"); - return null; - } - return new FeatureWrapper(index, batch); - } -}