Merge branch 'feature/web-listener-toggles'
This commit is contained in:
@@ -52,6 +52,8 @@ type mysqlConfig struct {
|
||||
|
||||
type webConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
PortEnabled bool `yaml:"port_enabled"`
|
||||
SocketEnabled bool `yaml:"socket_enabled"`
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
SocketPath string `yaml:"socket_path"`
|
||||
@@ -106,6 +108,8 @@ type rawMySQLConfig struct {
|
||||
|
||||
type rawWebConfig struct {
|
||||
Enabled *bool `yaml:"enabled"`
|
||||
PortEnabled *bool `yaml:"port_enabled"`
|
||||
SocketEnabled *bool `yaml:"socket_enabled"`
|
||||
Host *string `yaml:"host"`
|
||||
Port *int `yaml:"port"`
|
||||
SocketPath *string `yaml:"socket_path"`
|
||||
@@ -143,6 +147,8 @@ func defaultConfig() *config {
|
||||
},
|
||||
Web: webConfig{
|
||||
Enabled: true,
|
||||
PortEnabled: true,
|
||||
SocketEnabled: defaultWebSocketPath() != "",
|
||||
Host: "0.0.0.0",
|
||||
Port: 8080,
|
||||
SocketPath: defaultWebSocketPath(),
|
||||
@@ -160,12 +166,20 @@ func defaultConfig() *config {
|
||||
|
||||
// defaultConfigDir 根据操作系统返回配置目录。
|
||||
func defaultConfigDir() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return defaultConfigDirForGOOS(runtime.GOOS)
|
||||
}
|
||||
|
||||
func defaultConfigDirForGOOS(goos string) string {
|
||||
if useRelativeDefaultPath(goos) {
|
||||
return filepath.Join(".", "win", "etc", "mesh_mqtt_go")
|
||||
}
|
||||
return filepath.Join(string(filepath.Separator), "etc", "mesh_mqtt_go")
|
||||
}
|
||||
|
||||
func useRelativeDefaultPath(goos string) bool {
|
||||
return goos == "windows" || goos == "darwin"
|
||||
}
|
||||
|
||||
// defaultConfigPath 返回默认配置文件路径。
|
||||
func defaultConfigPath() string {
|
||||
return filepath.Join(defaultConfigDir(), configFileName)
|
||||
@@ -184,7 +198,7 @@ func defaultMapTileCacheDir() string {
|
||||
}
|
||||
|
||||
func defaultMapTileCacheDirForGOOS(goos string) string {
|
||||
if goos == "windows" {
|
||||
if useRelativeDefaultPath(goos) {
|
||||
return filepath.Join(".", "win", "srv", "mesh_mqtt_go")
|
||||
}
|
||||
return filepath.Join(string(filepath.Separator), "srv", "mesh_mqtt_go")
|
||||
@@ -194,19 +208,30 @@ func defaultWebSocketPathForGOOS(goos string) string {
|
||||
if goos == "windows" {
|
||||
return ""
|
||||
}
|
||||
if useRelativeDefaultPath(goos) {
|
||||
return filepath.Join(".", "win", "opt", "mesh_mqtt_go", "web.sock")
|
||||
}
|
||||
return filepath.Join(string(filepath.Separator), "opt", "mesh_mqtt_go", "web.sock")
|
||||
}
|
||||
|
||||
func clearWebSocketPathOnUnsupportedGOOS(cfg *config, goos string) bool {
|
||||
if goos != "windows" || cfg.Web.SocketPath == "" {
|
||||
if goos != "windows" {
|
||||
return false
|
||||
}
|
||||
changed := false
|
||||
if cfg.Web.SocketPath != "" {
|
||||
cfg.Web.SocketPath = ""
|
||||
return true
|
||||
changed = true
|
||||
}
|
||||
if cfg.Web.SocketEnabled {
|
||||
cfg.Web.SocketEnabled = false
|
||||
changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
func defaultSQLitePathForGOOS(goos string) string {
|
||||
if goos == "windows" {
|
||||
if useRelativeDefaultPath(goos) {
|
||||
return filepath.Join(".", "win", "etc", "mesh_mqtt_go", "mesh_mqtt_go.db")
|
||||
}
|
||||
return filepath.Join(string(filepath.Separator), "srv", "mesh_mqtt_go", "mesh_mqtt_go.db")
|
||||
@@ -336,6 +361,16 @@ func normalizeConfig(raw rawConfig) (*config, bool) {
|
||||
} else {
|
||||
cfg.Web.Enabled = *raw.Web.Enabled
|
||||
}
|
||||
if raw.Web.PortEnabled == nil {
|
||||
changed = true
|
||||
} else {
|
||||
cfg.Web.PortEnabled = *raw.Web.PortEnabled
|
||||
}
|
||||
if raw.Web.SocketEnabled == nil {
|
||||
changed = true
|
||||
} else {
|
||||
cfg.Web.SocketEnabled = *raw.Web.SocketEnabled
|
||||
}
|
||||
if raw.Web.Host == nil {
|
||||
changed = true
|
||||
} else {
|
||||
@@ -407,9 +442,15 @@ func validateConfig(cfg *config) error {
|
||||
return fmt.Errorf("invalid database.driver %q: must be sqlite or mysql", cfg.Database.Driver)
|
||||
}
|
||||
if cfg.Web.Enabled {
|
||||
if cfg.Web.SocketPath == "" && (cfg.Web.Port <= 0 || cfg.Web.Port > 65535) {
|
||||
if !cfg.Web.PortEnabled && !cfg.Web.SocketEnabled {
|
||||
return fmt.Errorf("web.port_enabled and web.socket_enabled cannot both be false when web is enabled")
|
||||
}
|
||||
if cfg.Web.PortEnabled && (cfg.Web.Port <= 0 || cfg.Web.Port > 65535) {
|
||||
return fmt.Errorf("invalid web port %d: must be 1-65535", cfg.Web.Port)
|
||||
}
|
||||
if cfg.Web.SocketEnabled && cfg.Web.SocketPath == "" {
|
||||
return fmt.Errorf("web.socket_path is required when web.socket_enabled is true")
|
||||
}
|
||||
if cfg.Web.StaticDir == "" {
|
||||
return fmt.Errorf("web.static_dir is required when web is enabled")
|
||||
}
|
||||
|
||||
+71
-15
@@ -35,6 +35,13 @@ func TestLoadConfigCreatesDefaultFile(t *testing.T) {
|
||||
if !cfg.Web.Enabled {
|
||||
t.Fatalf("web enabled = false, want true")
|
||||
}
|
||||
if !cfg.Web.PortEnabled {
|
||||
t.Fatalf("web port enabled = false, want true")
|
||||
}
|
||||
wantSocketEnabled := defaultWebSocketPath() != ""
|
||||
if cfg.Web.SocketEnabled != wantSocketEnabled {
|
||||
t.Fatalf("web socket enabled = %t, want %t", cfg.Web.SocketEnabled, wantSocketEnabled)
|
||||
}
|
||||
if cfg.Web.Port != 8080 {
|
||||
t.Fatalf("web port = %d, want 8080", cfg.Web.Port)
|
||||
}
|
||||
@@ -83,7 +90,7 @@ func TestLoadConfigFillsMissingFields(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := string(data)
|
||||
for _, want := range []string{"host:", "tls:", "enabled:", "cert_file:", "key_file:", "meshtastic:", "psk:", "database:", "driver:", "sqlite:", "mysql:", "dsn:", "web:", "port:", "socket_path:", "static_dir:", "map_tile_cache_dir:"} {
|
||||
for _, want := range []string{"host:", "tls:", "enabled:", "cert_file:", "key_file:", "meshtastic:", "psk:", "database:", "driver:", "sqlite:", "mysql:", "dsn:", "web:", "port_enabled:", "socket_enabled:", "port:", "socket_path:", "static_dir:", "map_tile_cache_dir:"} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("completed config missing %q in:\n%s", want, text)
|
||||
}
|
||||
@@ -117,7 +124,7 @@ func TestLoadConfigPreservesExplicitWebFalse(t *testing.T) {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := "web:\n enabled: false\n host: 127.0.0.1\n port: 8081\n static_dir: ./public\n"
|
||||
content := "web:\n enabled: false\n port_enabled: false\n socket_enabled: false\n host: 127.0.0.1\n port: 8081\n static_dir: ./public\n"
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -129,6 +136,9 @@ func TestLoadConfigPreservesExplicitWebFalse(t *testing.T) {
|
||||
if cfg.Web.Enabled {
|
||||
t.Fatalf("web enabled = true, want explicit false")
|
||||
}
|
||||
if cfg.Web.PortEnabled || cfg.Web.SocketEnabled {
|
||||
t.Fatalf("web listener enabled = %t/%t, want explicit false/false", cfg.Web.PortEnabled, cfg.Web.SocketEnabled)
|
||||
}
|
||||
if cfg.Web.Host != "127.0.0.1" || cfg.Web.Port != 8081 || cfg.Web.StaticDir != "./public" {
|
||||
t.Fatalf("web config = %#v", cfg.Web)
|
||||
}
|
||||
@@ -157,11 +167,29 @@ func TestLoadConfigMalformedYAMLDoesNotOverwrite(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultConfigDirForGOOS(t *testing.T) {
|
||||
wantRelative := filepath.Join(".", "win", "etc", "mesh_mqtt_go")
|
||||
for _, goos := range []string{"windows", "darwin"} {
|
||||
path := defaultConfigDirForGOOS(goos)
|
||||
if path != wantRelative {
|
||||
t.Fatalf("%s config dir = %q, want %q", goos, path, wantRelative)
|
||||
}
|
||||
}
|
||||
|
||||
linuxPath := defaultConfigDirForGOOS("linux")
|
||||
wantLinux := filepath.Join(string(filepath.Separator), "etc", "mesh_mqtt_go")
|
||||
if linuxPath != wantLinux {
|
||||
t.Fatalf("linux config dir = %q, want %q", linuxPath, wantLinux)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultMapTileCacheDirForGOOS(t *testing.T) {
|
||||
windowsPath := defaultMapTileCacheDirForGOOS("windows")
|
||||
wantWindows := filepath.Join(".", "win", "srv", "mesh_mqtt_go")
|
||||
if windowsPath != wantWindows {
|
||||
t.Fatalf("windows map tile cache dir = %q, want %q", windowsPath, wantWindows)
|
||||
wantRelative := filepath.Join(".", "win", "srv", "mesh_mqtt_go")
|
||||
for _, goos := range []string{"windows", "darwin"} {
|
||||
path := defaultMapTileCacheDirForGOOS(goos)
|
||||
if path != wantRelative {
|
||||
t.Fatalf("%s map tile cache dir = %q, want %q", goos, path, wantRelative)
|
||||
}
|
||||
}
|
||||
|
||||
linuxPath := defaultMapTileCacheDirForGOOS("linux")
|
||||
@@ -176,6 +204,12 @@ func TestDefaultWebSocketPathForGOOS(t *testing.T) {
|
||||
t.Fatalf("windows web socket path = %q, want empty", windowsPath)
|
||||
}
|
||||
|
||||
darwinPath := defaultWebSocketPathForGOOS("darwin")
|
||||
wantDarwin := filepath.Join(".", "win", "opt", "mesh_mqtt_go", "web.sock")
|
||||
if darwinPath != wantDarwin {
|
||||
t.Fatalf("darwin web socket path = %q, want %q", darwinPath, wantDarwin)
|
||||
}
|
||||
|
||||
linuxPath := defaultWebSocketPathForGOOS("linux")
|
||||
want := filepath.Join(string(filepath.Separator), "opt", "mesh_mqtt_go", "web.sock")
|
||||
if linuxPath != want {
|
||||
@@ -192,6 +226,9 @@ func TestClearWebSocketPathOnUnsupportedGOOS(t *testing.T) {
|
||||
if cfg.Web.SocketPath != "" {
|
||||
t.Fatalf("windows web socket path = %q, want empty", cfg.Web.SocketPath)
|
||||
}
|
||||
if cfg.Web.SocketEnabled {
|
||||
t.Fatalf("windows web socket enabled = true, want false")
|
||||
}
|
||||
|
||||
cfg.Web.SocketPath = "/opt/mesh_mqtt_go/web.sock"
|
||||
if clearWebSocketPathOnUnsupportedGOOS(cfg, "linux") {
|
||||
@@ -203,9 +240,12 @@ func TestClearWebSocketPathOnUnsupportedGOOS(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDefaultSQLitePathForGOOS(t *testing.T) {
|
||||
windowsPath := defaultSQLitePathForGOOS("windows")
|
||||
if !strings.Contains(windowsPath, filepath.Join("win", "etc", "mesh_mqtt_go", "mesh_mqtt_go.db")) {
|
||||
t.Fatalf("windows sqlite path = %q", windowsPath)
|
||||
wantRelative := filepath.Join(".", "win", "etc", "mesh_mqtt_go", "mesh_mqtt_go.db")
|
||||
for _, goos := range []string{"windows", "darwin"} {
|
||||
path := defaultSQLitePathForGOOS(goos)
|
||||
if path != wantRelative {
|
||||
t.Fatalf("%s sqlite path = %q, want %q", goos, path, wantRelative)
|
||||
}
|
||||
}
|
||||
|
||||
linuxPath := defaultSQLitePathForGOOS("linux")
|
||||
@@ -238,24 +278,38 @@ func TestValidateConfigDatabase(t *testing.T) {
|
||||
|
||||
func TestValidateConfigWeb(t *testing.T) {
|
||||
cfg := defaultConfig()
|
||||
cfg.Web.SocketPath = ""
|
||||
cfg.Web.Port = 0
|
||||
if err := validateConfig(cfg); err == nil || !strings.Contains(err.Error(), "web port") {
|
||||
t.Fatalf("invalid web port error = %v, want web port error", err)
|
||||
}
|
||||
|
||||
cfg = defaultConfig()
|
||||
cfg.Web.SocketPath = filepath.Join(string(filepath.Separator), "tmp", "mesh_mqtt_go.sock")
|
||||
cfg.Web.PortEnabled = false
|
||||
cfg.Web.Port = 0
|
||||
if err := validateConfig(cfg); err != nil {
|
||||
t.Fatalf("web socket with invalid port error = %v, want nil", err)
|
||||
t.Fatalf("disabled web port with invalid port error = %v, want nil", err)
|
||||
}
|
||||
|
||||
cfg = defaultConfig()
|
||||
cfg.Web.SocketEnabled = false
|
||||
cfg.Web.SocketPath = ""
|
||||
cfg.Web.Port = 0
|
||||
if err := validateConfig(cfg); err == nil || !strings.Contains(err.Error(), "web port") {
|
||||
t.Fatalf("invalid web port without socket error = %v, want web port error", err)
|
||||
if err := validateConfig(cfg); err != nil {
|
||||
t.Fatalf("disabled web socket with empty path error = %v, want nil", err)
|
||||
}
|
||||
|
||||
cfg = defaultConfig()
|
||||
cfg.Web.PortEnabled = false
|
||||
cfg.Web.SocketEnabled = true
|
||||
cfg.Web.SocketPath = ""
|
||||
if err := validateConfig(cfg); err == nil || !strings.Contains(err.Error(), "web.socket_path") {
|
||||
t.Fatalf("missing web socket path error = %v, want web.socket_path error", err)
|
||||
}
|
||||
|
||||
cfg = defaultConfig()
|
||||
cfg.Web.PortEnabled = false
|
||||
cfg.Web.SocketEnabled = false
|
||||
if err := validateConfig(cfg); err == nil || !strings.Contains(err.Error(), "web.port_enabled") {
|
||||
t.Fatalf("disabled web listeners error = %v, want web.port_enabled error", err)
|
||||
}
|
||||
|
||||
cfg = defaultConfig()
|
||||
@@ -272,6 +326,8 @@ func TestValidateConfigWeb(t *testing.T) {
|
||||
|
||||
cfg = defaultConfig()
|
||||
cfg.Web.Enabled = false
|
||||
cfg.Web.PortEnabled = false
|
||||
cfg.Web.SocketEnabled = false
|
||||
cfg.Web.Port = 0
|
||||
cfg.Web.StaticDir = ""
|
||||
if err := validateConfig(cfg); err != nil {
|
||||
|
||||
@@ -179,9 +179,11 @@ func parseArgs() (*config, error) {
|
||||
flag.StringVar(&cfg.Database.SQLite.Path, "sqlite-path", cfg.Database.SQLite.Path, "SQLite database file path")
|
||||
flag.StringVar(&cfg.Database.MySQL.DSN, "mysql-dsn", cfg.Database.MySQL.DSN, "MySQL database DSN")
|
||||
flag.BoolVar(&cfg.Web.Enabled, "web", cfg.Web.Enabled, "Enable Gin web server")
|
||||
flag.BoolVar(&cfg.Web.PortEnabled, "web-port-enabled", cfg.Web.PortEnabled, "Enable web server on TCP host and port")
|
||||
flag.BoolVar(&cfg.Web.SocketEnabled, "web-socket-enabled", cfg.Web.SocketEnabled, "Enable web server on Unix socket; unsupported on Windows")
|
||||
flag.StringVar(&cfg.Web.Host, "web-host", cfg.Web.Host, "Web server listen host")
|
||||
flag.IntVar(&cfg.Web.Port, "web-port", cfg.Web.Port, "Web server listen port")
|
||||
flag.StringVar(&cfg.Web.SocketPath, "web-socket-path", cfg.Web.SocketPath, "Web server Unix socket path; empty uses host and port; unsupported on Windows")
|
||||
flag.StringVar(&cfg.Web.SocketPath, "web-socket-path", cfg.Web.SocketPath, "Web server Unix socket path; unsupported on Windows")
|
||||
flag.StringVar(&cfg.Web.StaticDir, "web-static-dir", cfg.Web.StaticDir, "Web frontend static files directory")
|
||||
flag.StringVar(&cfg.Web.MapTileCacheDir, "web-map-tile-cache-dir", cfg.Web.MapTileCacheDir, "Map tile disk cache root directory")
|
||||
flag.StringVar(&cfg.Web.Admin.Username, "admin-username", cfg.Web.Admin.Username, "Web admin username")
|
||||
@@ -245,31 +247,44 @@ func run(cfg *config) error {
|
||||
}
|
||||
defer forwardManager.StopAll()
|
||||
|
||||
var httpServer *http.Server
|
||||
errCh := make(chan error, 1)
|
||||
var httpServers []*http.Server
|
||||
errCh := make(chan error, 2)
|
||||
if cfg.Web.Enabled {
|
||||
sessions, err := newSessionManager(cfg.Web.Admin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mqttStatus := mqttRuntimeStatus{server: server, address: mqttAddr, tls: cfg.MQTT.TLS.Enabled, stats: messageStats, dbQueue: dbQueue}
|
||||
httpServer = newHTTPServer(cfg.Web, store, sessions, mqttStatus, blocking, forwardManager, settings, botSender)
|
||||
webAddress := httpServer.Addr
|
||||
handler := newRouter(cfg.Web, store, sessions, mqttStatus, blocking, forwardManager, settings, botSender)
|
||||
webAddresses := []string{}
|
||||
if cfg.Web.PortEnabled {
|
||||
httpServer := &http.Server{
|
||||
Addr: net.JoinHostPort(cfg.Web.Host, strconv.Itoa(cfg.Web.Port)),
|
||||
Handler: handler,
|
||||
}
|
||||
httpServers = append(httpServers, httpServer)
|
||||
webAddresses = append(webAddresses, httpServer.Addr)
|
||||
go func() {
|
||||
if cfg.Web.SocketPath != "" {
|
||||
if err := serveHTTPUnixSocket(httpServer, cfg.Web.SocketPath); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
errCh <- err
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
errCh <- err
|
||||
}
|
||||
}()
|
||||
if cfg.Web.SocketPath != "" {
|
||||
webAddress = cfg.Web.SocketPath
|
||||
}
|
||||
printJSON(map[string]any{"event": "web_started", "address": webAddress, "static_dir": cfg.Web.StaticDir})
|
||||
if cfg.Web.SocketEnabled {
|
||||
httpServer := &http.Server{Handler: handler}
|
||||
httpServers = append(httpServers, httpServer)
|
||||
webAddresses = append(webAddresses, cfg.Web.SocketPath)
|
||||
go func() {
|
||||
if err := serveHTTPUnixSocket(httpServer, cfg.Web.SocketPath); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
errCh <- err
|
||||
}
|
||||
}()
|
||||
}
|
||||
webStarted := map[string]any{"event": "web_started", "addresses": webAddresses, "static_dir": cfg.Web.StaticDir}
|
||||
if len(webAddresses) > 0 {
|
||||
webStarted["address"] = webAddresses[0]
|
||||
}
|
||||
printJSON(webStarted)
|
||||
}
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
@@ -281,12 +296,12 @@ func run(cfg *config) error {
|
||||
case runErr = <-errCh:
|
||||
}
|
||||
|
||||
if httpServer != nil {
|
||||
for _, httpServer := range httpServers {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := httpServer.Shutdown(ctx); err != nil && runErr == nil {
|
||||
runErr = err
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
if err := server.Close(); err != nil && runErr == nil {
|
||||
runErr = err
|
||||
|
||||
@@ -40,15 +40,58 @@ function applyTileLayer() {
|
||||
}).addTo(map)
|
||||
}
|
||||
|
||||
function pointStyle(isStart: boolean, isEnd: boolean): L.CircleMarkerOptions {
|
||||
if (isStart) {
|
||||
return { radius: 7, color: '#7f9183', fillColor: '#9aaa95', fillOpacity: 0.88, weight: 2 }
|
||||
}
|
||||
if (isEnd) {
|
||||
return { radius: 7, color: '#b4877f', fillColor: '#c59b93', fillOpacity: 0.88, weight: 2 }
|
||||
}
|
||||
return { radius: 3, color: '#7d8f9a', fillColor: '#9ab3c2', fillOpacity: 0.72, weight: 1 }
|
||||
}
|
||||
|
||||
function buildPositionPopupHTML(position: PositionRecord, isStart: boolean, isEnd: boolean): string {
|
||||
const title = isStart ? '起点' : isEnd ? '终点' : '轨迹点'
|
||||
const latitude = position.latitude == null ? '-' : position.latitude.toFixed(6)
|
||||
const longitude = position.longitude == null ? '-' : position.longitude.toFixed(6)
|
||||
const altitude = position.altitude == null ? '-' : `${position.altitude} m`
|
||||
const time = new Date(position.created_at).toLocaleString()
|
||||
return `
|
||||
<div class="trajectory-popup">
|
||||
<strong>${escapeHTML(title)}</strong>
|
||||
<dl>
|
||||
<div><dt>纬度</dt><dd>${latitude}</dd></div>
|
||||
<div><dt>经度</dt><dd>${longitude}</dd></div>
|
||||
<div><dt>海拔</dt><dd>${altitude}</dd></div>
|
||||
<div><dt>时间</dt><dd>${escapeHTML(time)}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
function escapeHTML(value: string): string {
|
||||
return value.replace(/[&<>'"]/g, (char) => {
|
||||
const entities: Record<string, string> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
"'": ''',
|
||||
'"': '"',
|
||||
}
|
||||
return entities[char]
|
||||
})
|
||||
}
|
||||
|
||||
function renderTrajectory() {
|
||||
if (!map || !layer) {
|
||||
return
|
||||
}
|
||||
layer.clearLayers()
|
||||
const points = [...props.positions]
|
||||
const trajectoryLayer = layer
|
||||
trajectoryLayer.clearLayers()
|
||||
const positions = [...props.positions]
|
||||
.filter((position) => position.latitude != null && position.longitude != null)
|
||||
.reverse()
|
||||
.map((position) => [position.latitude as number, position.longitude as number] as L.LatLngTuple)
|
||||
const points = positions.map((position) => [position.latitude as number, position.longitude as number] as L.LatLngTuple)
|
||||
|
||||
if (points.length === 0) {
|
||||
map.setView([0, 0], 2)
|
||||
@@ -56,10 +99,16 @@ function renderTrajectory() {
|
||||
}
|
||||
|
||||
if (points.length > 1) {
|
||||
L.polyline(points, { color: '#7d8f9a', weight: 4, opacity: 0.78 }).addTo(layer)
|
||||
L.polyline(points, { color: '#7d8f9a', weight: 4, opacity: 0.78 }).addTo(trajectoryLayer)
|
||||
}
|
||||
L.circleMarker(points[0], { radius: 6, color: '#7f9183', fillColor: '#9aaa95', fillOpacity: 0.88 }).bindPopup('起点').addTo(layer)
|
||||
L.circleMarker(points[points.length - 1], { radius: 6, color: '#b4877f', fillColor: '#c59b93', fillOpacity: 0.88 }).bindPopup('终点').addTo(layer)
|
||||
|
||||
positions.forEach((position, index) => {
|
||||
const isStart = index === 0
|
||||
const isEnd = index === positions.length - 1
|
||||
L.circleMarker(points[index], pointStyle(isStart, isEnd))
|
||||
.bindPopup(buildPositionPopupHTML(position, isStart, isEnd), { maxWidth: 280, className: 'trajectory-point-popup' })
|
||||
.addTo(trajectoryLayer)
|
||||
})
|
||||
map.fitBounds(L.latLngBounds(points), { padding: [24, 24], maxZoom: 14 })
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user