Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 13 additions & 9 deletions src/web/src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@
import { GoldenLayout, LayoutConfig } from 'https://esm.sh/golden-layout@2.6.0';
import { latLngToDbu } from './coordinates.js';
import { WebSocketManager } from './websocket-manager.js';
import { createWebSocketTileLayer } from './websocket-tile-layer.js';
import {
createWebSocketTileLayer,
floorClampZoom,
} from './websocket-tile-layer.js';
import { TimingWidget } from './timing-widget.js';
import { ClockTreeWidget } from './clock-tree-widget.js';
import { ChartsWidget } from './charts-widget.js';
import { HierarchyBrowser } from './hierarchy-browser.js';
import { createInspectorPanel } from './inspector.js';
import { isStaticMode } from './ui-utils.js';
import { isStaticMode, buildMapOptions } from './ui-utils.js';
import { populateDisplayControls } from './display-controls.js';
import { createMenuBar } from './menu-bar.js';
import { RulerManager } from './ruler.js';
Expand Down Expand Up @@ -268,6 +271,13 @@ const HeatMapTileLayer = L.GridLayer.extend({
L.GridLayer.prototype.initialize.call(this, options);
},

// Upscale-only display, same as the layout tile layer: the map rests on
// integer zoom so heatmap tiles show 1:1 with no fractional rescaling.
_clampZoom: function(zoom) {
return L.GridLayer.prototype._clampZoom.call(
this, floorClampZoom(this, zoom));
},

createTile: function(coords, done) {
const tile = document.createElement('img');
tile.alt = '';
Expand Down Expand Up @@ -421,13 +431,7 @@ function createLayoutViewer(container) {
mapDiv.appendChild(heatMapLegend);
app.heatMapLegendEl = heatMapLegend;

app.map = L.map(mapDiv, {
crs: L.CRS.Simple,
zoom: 1,
zoomSnap: 0,
fadeAnimation: false,
attributionControl: false,
});
app.map = L.map(mapDiv, buildMapOptions());
const hoverPane = app.map.createPane(app.hoverHighlightPane);
hoverPane.style.zIndex = '650';
hoverPane.style.pointerEvents = 'none';
Expand Down
158 changes: 142 additions & 16 deletions src/web/src/request_handler.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,28 @@ void writePayload(WebSocketResponse& resp, const boost::json::value& v)

} // namespace

// Clamp + quantize the client's devicePixelRatio so the server renders tiles
// at a stable 256*dpr and the tile cache has few buckets. Snaps to the common
// HiDPI ratios; anything outside [1,3] or non-finite falls back to 1.0.
static double quantizeDpr(const double raw)
{
if (!std::isfinite(raw) || raw <= 1.0) {
return 1.0;
}
const double clamped = std::min(raw, 3.0);
constexpr double kSteps[] = {1.0, 1.25, 1.5, 2.0, 3.0};
double best = 1.0;
double best_err = std::numeric_limits<double>::max();
for (const double step : kSteps) {
const double err = std::abs(step - clamped);
if (err < best_err) {
best_err = err;
best = step;
}
}
return best;
}

// Store a Selected in the clickables vector and return its index.
static int storeSelectable(std::vector<gui::Selected>& selectables,
const gui::Selected& sel)
Expand Down Expand Up @@ -382,7 +404,8 @@ WebSocketResponse TileHandler::renderTile(
const std::vector<FlightLine>& flight_lines,
const std::map<uint32_t, Color>* module_colors,
const std::set<uint32_t>* focus_net_ids,
const std::set<uint32_t>* route_guide_net_ids)
const std::set<uint32_t>* route_guide_net_ids,
const double dpr)
{
WebSocketResponse resp;
resp.id = id;
Expand All @@ -398,7 +421,8 @@ WebSocketResponse TileHandler::renderTile(
flight_lines,
module_colors,
focus_net_ids,
route_guide_net_ids);
route_guide_net_ids,
dpr);
return resp;
}

Expand Down Expand Up @@ -1665,6 +1689,15 @@ void TileHandler::registerRequests(RequestDispatcher& d)
[this](const WebSocketRequest& req, SessionState& state) {
return handleTile(req, state);
});
// run_inline so the cancel runs on the read thread, ahead of the posted
// tile render it is meant to abort.
d.add(
"cancel",
WebSocketRequest::kCancel,
[this](const WebSocketRequest& req, SessionState& state) {
return handleCancel(req, state);
},
/*run_inline=*/true);
d.add("module_hierarchy",
WebSocketRequest::kModuleHierarchy,
[this](const WebSocketRequest& req, SessionState&) {
Expand Down Expand Up @@ -1728,6 +1761,19 @@ WebSocketResponse TileHandler::handleTile(const WebSocketRequest& req,
}
}

// Skip a render the client abandoned while it sat queued (best-effort).
{
std::lock_guard<std::mutex> lock(state.cancelled_mutex);
if (state.cancelled_ids.erase(req.id) > 0) {
WebSocketResponse resp;
resp.id = req.id;
resp.type = WebSocketResponse::kError;
const std::string msg = "cancelled";
resp.payload.assign(msg.begin(), msg.end());
return resp;
}
}

TileVisibility vis;
vis.parseFromJson(req.json);
// When debug renderers are active, instance positions change between
Expand Down Expand Up @@ -1792,20 +1838,100 @@ WebSocketResponse TileHandler::handleTile(const WebSocketRequest& req,
const std::set<uint32_t>* route_guide_ptr
= route_guides.empty() ? nullptr : &route_guides;

return renderTile(req.id,
std::string(req.json.at("layer").as_string()),
static_cast<int>(req.json.at("z").as_int64()),
static_cast<int>(req.json.at("x").as_int64()),
static_cast<int>(req.json.at("y").as_int64()),
vis,
*gen_,
rects,
polys,
colored,
lines,
mod_ptr,
focus_ptr,
route_guide_ptr);
const double dpr = quantizeDpr(jsonOr<double>(req.json, "dpr", 1.0));

// A tile is cacheable only when it depends solely on the static design +
// visibility + dpr — i.e. no per-session overlays are active. That keeps
// the cache session-independent and correct; overlay tiles render fresh.
const bool cacheable = rects.empty() && polys.empty() && colored.empty()
&& lines.empty() && mod_ptr == nullptr
&& focus_ptr == nullptr && route_guide_ptr == nullptr
&& !vis.debug && !vis.debug_renderers
&& !vis.debug_live;

std::string cache_key;
if (cacheable) {
// Key = the full render determinant: the request JSON minus the per-call
// id, with dpr pinned to the quantized value actually rendered.
// Selectability (the s_* flags and selectable_layers) does NOT affect the
// rendered tile, so it is excluded — toggling "selectable" must not
// invalidate the cache.
boost::json::object key_obj = req.json;
key_obj.erase("id");
key_obj.erase("selectable_layers");
std::vector<std::string> sel_keys;
for (const auto& kv : key_obj) {
const std::string_view k = kv.key();
if (k.size() >= 2 && k[0] == 's' && k[1] == '_') {
sel_keys.emplace_back(k);
}
}
for (const std::string& k : sel_keys) {
key_obj.erase(k);
}
key_obj["dpr"] = dpr;
cache_key = boost::json::serialize(key_obj);
std::vector<unsigned char> cached;
if (gen_->tileCacheGet(cache_key, cached)) {
WebSocketResponse resp;
resp.id = req.id;
resp.type = WebSocketResponse::kPng;
resp.payload = std::move(cached);
return resp;
}
}

WebSocketResponse resp
= renderTile(req.id,
std::string(req.json.at("layer").as_string()),
static_cast<int>(req.json.at("z").as_int64()),
static_cast<int>(req.json.at("x").as_int64()),
static_cast<int>(req.json.at("y").as_int64()),
vis,
*gen_,
rects,
polys,
colored,
lines,
mod_ptr,
focus_ptr,
route_guide_ptr,
dpr);
if (cacheable && resp.type == WebSocketResponse::kPng) {
gen_->tileCachePut(std::move(cache_key), resp.payload);
}
return resp;
}

WebSocketResponse TileHandler::handleCancel(const WebSocketRequest& req,
SessionState& state)
{
// Cap so a long browsing session whose cancels never match a queued render
// can't grow the set without bound; trimming the oldest (lowest) ids only
// costs an occasional missed cancel (the render proceeds, which is correct).
constexpr size_t kCancelledCap = 4096;
{
std::lock_guard<std::mutex> lock(state.cancelled_mutex);
if (const auto* ids = req.json.if_contains("cancel_ids")) {
for (const auto& v : ids->as_array()) {
state.cancelled_ids.insert(static_cast<uint32_t>(v.as_int64()));
}
} else {
state.cancelled_ids.insert(
static_cast<uint32_t>(jsonOr<int64_t>(req.json, "cancel_id", 0)));
}
while (state.cancelled_ids.size() > kCancelledCap) {
state.cancelled_ids.erase(state.cancelled_ids.begin());
}
}
// Minimal ack. The client does not track the cancel message in `pending`,
// so this response is harmlessly ignored.
WebSocketResponse resp;
resp.id = req.id;
resp.type = WebSocketResponse::kJson;
const std::string ok = "{\"cancelled\":1}";
resp.payload.assign(ok.begin(), ok.end());
return resp;
}

WebSocketResponse TileHandler::handleModuleHierarchy(
Expand Down
16 changes: 15 additions & 1 deletion src/web/src/request_handler.h
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ struct WebSocketRequest
kDebugContinue,
kDebugCharts,
kGet3DData,
kCancel,
kUnknown
};

Expand Down Expand Up @@ -186,6 +187,13 @@ struct SessionState
std::mutex heatmap_mutex;
std::map<std::string, std::shared_ptr<gui::HeatMapDataSource>> heatmaps;
std::string active_heatmap;

// Tile-request ids the client has abandoned (pan/zoom away). Populated by
// the inline `cancel` handler and consumed at the top of handleTile so a
// still-queued render is skipped. Best-effort (a render already running on
// a worker thread is not interrupted).
std::mutex cancelled_mutex;
std::set<uint32_t> cancelled_ids;
};

// Optional-field accessor: returns the JSON value at `key` converted to T,
Expand Down Expand Up @@ -313,6 +321,11 @@ class TileHandler
SessionState& state);
WebSocketResponse handleHeatMapTile(const WebSocketRequest& req,
SessionState& state);
// Marks a tile-request id as cancelled so a still-queued render is skipped.
// Registered run_inline so it executes on the read thread, ahead of the
// posted render it cancels.
WebSocketResponse handleCancel(const WebSocketRequest& req,
SessionState& state);

private:
static WebSocketResponse serializeBounds(uint32_t id,
Expand All @@ -332,7 +345,8 @@ class TileHandler
const std::vector<FlightLine>& flight_lines,
const std::map<uint32_t, Color>* module_colors,
const std::set<uint32_t>* focus_net_ids,
const std::set<uint32_t>* route_guide_net_ids);
const std::set<uint32_t>* route_guide_net_ids,
double dpr = 1.0);

std::shared_ptr<TileGenerator> gen_;
};
Expand Down
6 changes: 5 additions & 1 deletion src/web/src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -445,7 +445,11 @@ html, body {
cursor: grabbing !important;
}

/* Leaflet tiles */
/* Leaflet tiles.
Keep `auto` (bilinear): tiles are band-limited server-side and displayed
1:1 at rest (integer zoom) and HiDPI-exact (256*dpr image in a 256 CSS-px
box), so the only resampling is benign upscaling during zoom animation,
where bilinear is correct. `pixelated` would block-up that upscale. */
.leaflet-tile {
image-rendering: auto;
}
Expand Down
Loading
Loading