diff --git a/.eslintrc.js b/.eslintrc.js index 1d957a0..bc4b827 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -106,7 +106,7 @@ module.exports = { "comma-style": ["error", "last"], "complexity": "error", "computed-property-spacing": ["error", "never"], - "consistent-return": "error", + "consistent-return": "off", "consistent-this": "error", "curly": "error", "default-case": "error", @@ -124,7 +124,7 @@ module.exports = { "function-paren-newline": "off", "generator-star-spacing": ["error", { "before": false, "after": true }], "grouped-accessor-pairs": "error", - "guard-for-in": "error", + "guard-for-in": "off", "id-blacklist": "error", "id-length": "off", "id-match": "error", diff --git a/src/routes/tileserver.js b/src/routes/tileserver.js index b069d93..58ed8f5 100644 --- a/src/routes/tileserver.js +++ b/src/routes/tileserver.js @@ -1,15 +1,29 @@ "use strict"; const fs = require("fs-extra"); const path = require("path"); +const sharp = require("sharp"); + +module.exports = async function registerTileServer(app, tilesPath) { + // Create single color PNG for missing tiles using Sharp + const blackImage = await sharp({ + create: { + width: 256, + height: 256, + channels: 4, + background: { r: 0, g: 0, b: 0, alpha: 1 }, + // background: { r: 65, g: 57, b: 18, alpha: 1 }, // Forest green + }, + }) + .png() + .toBuffer(); -module.exports = function registerTileServer(app, tilesPath) { // Serve tiles app.get("/api/gridworld/tiles/:z/:x/:y.png", async (req, res) => { try { let file = await fs.readFile(path.resolve(tilesPath, `z${req.params.z}x${req.params.x}y${req.params.y}.png`)); res.send(file); } catch (e) { - res.status(404).send("Tile not found"); + res.send(blackImage); } }); }; diff --git a/web/components/GridVisualizer.jsx b/web/components/GridVisualizer.jsx index 9a3fdf7..9dbbb42 100644 --- a/web/components/GridVisualizer.jsx +++ b/web/components/GridVisualizer.jsx @@ -1,6 +1,7 @@ -import React, { useContext, useState } from "react"; +import React, { useContext, useState, useEffect } from "react"; import { Row, Col } from "antd"; -import { MapContainer, Polyline, Rectangle, Tooltip, TileLayer, SVGOverlay, Marker, Popup, Circle } from "react-leaflet"; +import { MapContainer, Polyline, Rectangle, Tooltip, SVGOverlay, Popup, Circle } from "react-leaflet"; +import { TileLayer as TileLayerCustom } from "./leaflet/TileLayerCustomReact"; import { ControlContext, useInstance, statusColors } from "@clusterio/web_ui"; import { useMapData } from "../model/mapData"; @@ -23,6 +24,14 @@ export default function GridVisualizer(props) { const playerPositions = usePlayerPosition(control); const [mapData] = useMapData(); const [activeInstance, setActiveInstance] = useState(); + const [refreshTiles, setRefreshTiles] = useState("1"); + + useEffect(() => { + const interval = setInterval(() => { + setRefreshTiles(Math.floor(Math.random() * 10000).toString()); + }, 2500); + return () => clearInterval(interval); + }); return <>
@@ -34,7 +43,6 @@ export default function GridVisualizer(props) { // eslint-disable-next-line max-len integrity="sha512-XQoYMqMTK8LvdxXYG3nZ448hOEQiglfqkJs1NOQV44cWnUrBc8PkAOcXy20w0vlaXaVUearIOBhiXZ5V3ynxwA==" crossOrigin=""> - {mapData.map_data?.length ? - @@ -110,8 +118,10 @@ function InstanceRender(props) { -1 * position[1] / scaleFactor, position[0] / scaleFactor, ]))} - onclick={() => { - props.setActiveInstance(props.instance.instance_id); + eventHandlers={{ + click: () => { + props.setActiveInstance(props.instance.instance_id); + }, }} color={props.instance.instance_id === props.activeInstance ? "#ffff00" : "#3388ff"} opacity={0.5} diff --git a/web/components/InstanceModal.jsx b/web/components/InstanceModal.jsx index 106d946..b08417c 100644 --- a/web/components/InstanceModal.jsx +++ b/web/components/InstanceModal.jsx @@ -22,14 +22,13 @@ import MigrateInstanceButton from "./MigrateInstanceButton"; function InstanceModal(props) { let control = useContext(ControlContext); let [instance] = useInstance(props.instance_id); - let [host] = useHost(instance?.["assigned_host"]); + let [host] = useHost(instance?.assignedHost); let account = useAccount(); let navigate = useNavigate(); return <> {props.instance_id && <> } > - {!instance.assigned_host + {!instance.assignedHost ? Unassigned - : host["name"] || instance["assigned_host"] + : host.name || instance.assignedHost } {instance["status"] && } - - { - account.hasAllPermission("core.instance.save.list", "core.instance.save.list_subscribe") - && - - - } - { - account.hasAnyPermission("core.log.follow", "core.instance.send_rcon") - && - Console - {account.hasPermission("core.log.follow") && } - {account.hasPermission("core.instance.send_rcon") - && } - - } - { - account.hasPermission("core.instance.get_config") - && - - - } - + , + }, { + disabled: !account.hasAnyPermission("core.log.follow", "core.instance.send_rcon"), + label: "Console", + key: "console", + children:
+ Console + {account.hasPermission("core.log.follow") && } + {account.hasPermission("core.instance.send_rcon") + && } +
, + }, { + disabled: !account.hasPermission("core.instance.get_config"), + label: "Config", + key: "config", + children: , + }, + ]} + /> } ; } diff --git a/web/components/MigrateInstanceModal.jsx b/web/components/MigrateInstanceModal.jsx index 96898db..4e5490e 100644 --- a/web/components/MigrateInstanceModal.jsx +++ b/web/components/MigrateInstanceModal.jsx @@ -53,10 +53,10 @@ export default function MigrateInstanceModal(props) {

- + {Array.from(hostList).map(host => {host.name} {!host.connected && " (offline)"} )} diff --git a/web/components/leaflet/TileLayerCustom.jsx b/web/components/leaflet/TileLayerCustom.jsx new file mode 100644 index 0000000..f16a93c --- /dev/null +++ b/web/components/leaflet/TileLayerCustom.jsx @@ -0,0 +1,35 @@ +/* eslint-disable guard-for-in */ +import L from "leaflet"; + +/** + * Custom TileLayer that allows for in-place replacement of tiles without flickering. + * The original TileLayer implementation gets flickering from 2 sources: + * - Delay while downloading tiles + * - 250ms fade-in animation for tiles + * The fade in animation is disabled with CSS opacity: 1 !important; + */ + +export const TileLayer = L.TileLayer.extend({ + _refreshTileUrl: function (tile, url) { + // use a image in background, so that only replace the actual tile, once image is loaded in cache! + let img = new Image(); + img.onload = function () { + L.Util.requestAnimFrame(() => { + tile.el.src = url; + }); + }; + img.src = url; + }, + refresh: function () { + for (let key in this._tiles) { + let tile = this._tiles[key]; + if (tile.current && tile.active) { + let oldsrc = tile.el.src; + let newsrc = this.getTileUrl(tile.coords); + if (oldsrc !== newsrc) { + this._refreshTileUrl(tile, newsrc); + } + } + } + }, +}); diff --git a/web/components/leaflet/TileLayerCustomReact.jsx b/web/components/leaflet/TileLayerCustomReact.jsx new file mode 100644 index 0000000..13b9d9f --- /dev/null +++ b/web/components/leaflet/TileLayerCustomReact.jsx @@ -0,0 +1,24 @@ +/* eslint-disable prefer-arrow-callback */ +import { + createElementObject, + createTileLayerComponent, + updateGridLayer, + withPane, +} from "@react-leaflet/core"; +import { TileLayer as TileLayerCustom } from "./TileLayerCustom"; + +export const TileLayer = createTileLayerComponent( + function createTileLayer({ url, ...options }, context) { + const layer = new TileLayerCustom(url, withPane(options, context)); + return createElementObject(layer, context); + }, + function updateTileLayer(layer, props, prevProps) { + updateGridLayer(layer, props, prevProps); + + const { url } = props; + if (url !== null && url !== prevProps.url) { + layer.setUrl(url, true); + layer.refresh(); + } + }, +);