Merge pull request #800 from DJ2LS/develop-svg-map

first heard station map
pull/802/head
DJ2LS 2024-09-11 21:55:55 +02:00 committed by GitHub
commit c37e494324
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 289 additions and 17 deletions

View File

@ -32,10 +32,12 @@
"chart.js": "^4.4.3",
"chartjs-plugin-annotation": "^3.0.1",
"core-js": "^3.8.3",
"d3": "^7.9.0",
"gridstack": "^10.3.0",
"js-image-compressor": "^2.0.0",
"pinia": "^2.1.7",
"qth-locator": "^2.1.0",
"topojson-client": "^3.1.0",
"uuid": "^10.0.0",
"vue": "^3.2.13",
"vue-chartjs": "^5.3.1",

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -33,6 +33,7 @@ import grid_mycall_small from "./grid/grid_mycall small.vue";
import grid_scatter from "./grid/grid_scatter.vue";
import grid_stats_chart from "./grid/grid_stats_chart.vue";
import grid_swr_meter from "./grid/grid_swr_meter.vue";
import grid_stations_map from "./grid/grid_stations_map.vue";
let count = ref(0);
let grid = null; // DO NOT use ref(null) as proxies GS will break all logic when comparing structures... see https://github.com/gridstack/gridstack.js/issues/2115
@ -263,8 +264,17 @@ const gridWidgets = [
true,
"Rig",
21
),
new gridWidget(
grid_stations_map,
{ x: 16, y: 0, w: 8, h: 100 },
"Station Map",
true,
true,
"Other",
22
)
//Next new widget ID should be 22
//Next new widget ID should be 23
];
function updateFrequencyAndApply(frequency) {

View File

@ -0,0 +1,260 @@
<template>
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<div class="btn-group btn-group-sm" role="group">
<button @click="zoomIn" class="btn btn-sm btn-outline-secondary"><i class="bi bi-plus-square"></i></button>
<button @click="centerMap" class="btn btn-sm btn-secondary"><i class="bi bi-house-door"></i></button>
<button @click="zoomOut" class="btn btn-sm btn-outline-secondary"><i class="bi bi-dash-square"></i></button>
</div>
<div class="input-group input-group-sm ms-2">
<input type="text" class="form-control w-100" placeholder="Station" aria-label="Username" aria-describedby="basic-addon1" v-model="infoText" disabled>
</div>
</div>
<div class="card-body">
<div ref="mapContainer" class="map-container"></div>
<div :style="popupStyle" class="popup">{{ infoText }}</div>
</div>
</div>
</template>
<script setup>
import { settingsStore as settings } from '../../store/settingsStore.js';
import { ref, onMounted, onBeforeUnmount, nextTick, watch, toRaw } from 'vue';
import * as d3 from 'd3';
import { feature } from 'topojson-client';
import { locatorToLatLng, distance } from 'qth-locator';
import { setActivePinia } from 'pinia';
import pinia from '../../store/index'; // Import your Pinia instance
import { useStateStore } from '../../store/stateStore.js';
// Activate Pinia
setActivePinia(pinia);
const state = useStateStore(pinia);
const mapContainer = ref(null);
const infoText = ref('');
const popupStyle = ref({});
let svg, path, projection, zoom;
const basePinRadius = 5; // Base radius for pins
let actualPinRadius = basePinRadius;
// Function to get distance between two grid squares
function getMaidenheadDistance(dxGrid) {
try {
return parseInt(distance(settings.remote.STATION.mygrid, dxGrid));
} catch (e) {
console.error('Error calculating distance:', e);
return null;
}
}
// Function to draw the map
const drawMap = () => {
const containerWidth = mapContainer.value.clientWidth;
const containerHeight = mapContainer.value.clientHeight;
// Clear existing SVG if it exists
if (svg) {
svg.remove();
}
// Create Mercator projection
projection = d3.geoMercator()
.scale(containerWidth / (2 * Math.PI))
.translate([containerWidth / 2, containerHeight / 2]);
path = d3.geoPath().projection(projection);
// Create SVG element
svg = d3.select(mapContainer.value)
.append('svg')
.attr('width', '100%')
.attr('height', '100%')
.attr('viewBox', `0 0 ${containerWidth} ${containerHeight}`)
.attr('preserveAspectRatio', 'xMidYMid meet');
// Set up zoom behavior
zoom = d3.zoom()
.scaleExtent([1, 8])
.on('zoom', (event) => {
svg.selectAll('g').attr('transform', event.transform);
// Adjust pin size and line width with zoom
actualPinRadius = basePinRadius / event.transform.k
svg.selectAll('.pin').attr('r', actualPinRadius);
svg.selectAll('.connection-line').attr('stroke-width', 1 / event.transform.k);
});
svg.call(zoom);
// Import JSON data dynamically
import('@/assets/countries-50m.json').then(worldData => {
const countriesGeoJSON = feature(worldData, worldData.objects.countries);
// Draw country paths
const g = svg.append('g');
g.selectAll('path')
.data(countriesGeoJSON.features)
.enter()
.append('path')
.attr('d', path)
.attr('fill', '#ccc')
.attr('stroke', '#333');
// Draw initial pins and lines
updatePinsAndLines(g);
});
};
// Function to update pins and draw lines
const updatePinsAndLines = (g) => {
// Remove existing pins and lines
g.selectAll('.pin').remove();
g.selectAll('.connection-line').remove();
const heardStations = toRaw(state.heard_stations); // Ensure it's the raw data
const points = [];
// Prepare points for heard stations
heardStations.forEach(item => {
if (item.gridsquare && item.origin) { // Ensure data is valid
const [lat, lng] = locatorToLatLng(item.gridsquare); // Convert gridsquare to lat/lng
points.push({ lat, lon: lng, origin: item.origin, gridsquare: item.gridsquare }); // Add to the points array
}
});
// Check if 'mygrid' is defined and not empty
const mygrid = settings.remote.STATION.mygrid;
if (mygrid) {
// Your station's coordinates
const [myLat, myLng] = locatorToLatLng(mygrid);
const [myX, myY] = projection([myLng, myLat]); // Project your station's coordinates
// Draw lines from your station to each heard station
g.selectAll('.connection-line')
.data(points)
.enter()
.append('line')
.attr('class', 'connection-line')
.attr('x1', myX)
.attr('y1', myY)
.attr('x2', d => projection([d.lon, d.lat])[0])
.attr('y2', d => projection([d.lon, d.lat])[1])
.attr('stroke', 'blue')
.attr('stroke-width', 1)
.attr('stroke-opacity', 0.5);
} else {
console.error('Error: Station grid square (mygrid) is not defined. Lines will not be drawn.');
}
// Add pins
g.selectAll('.pin')
.data(points)
.enter()
.append('circle')
.attr('class', 'pin')
.attr('r', actualPinRadius) // Set initial radius
.attr('fill', 'red')
.attr('cx', d => projection([d.lon, d.lat])[0])
.attr('cy', d => projection([d.lon, d.lat])[1])
.on('mouseover', (event, d) => {
// Show info with station details
infoText.value = `${d.origin} - ${d.gridsquare} (${getMaidenheadDistance(d.gridsquare)} km)`;
})
.on('mouseout', () => {
infoText.value = '';
});
};
// Handle window resize
const handleResize = () => {
drawMap();
};
// Watch for changes in heard_stations and update pins accordingly
watch(state.heard_stations, (changedHeardStations) => {
console.log('Heard stations updated:', toRaw(changedHeardStations));
const g = svg.select('g');
updatePinsAndLines(g);
});
// Zoom in function
const zoomIn = () => {
svg.transition().call(zoom.scaleBy, 1.2);
};
// Zoom out function
const zoomOut = () => {
svg.transition().call(zoom.scaleBy, 0.8);
};
// Center the map
const centerMap = () => {
const mygrid = settings.remote.STATION.mygrid; // Get the grid square value
if (!mygrid) {
console.error('Error: Station grid square (mygrid) is not defined.');
return; // Exit if 'mygrid' is not defined
}
const [lat, lng] = locatorToLatLng(mygrid); // Convert gridsquare to lat/lng
// Project the geographic coordinates to SVG coordinates
const [x, y] = projection([lng, lat]);
// Center the map at the calculated coordinates
svg.transition().duration(750).call(
zoom.translateTo,
x,
y
);
};
// Lifecycle hooks
onMounted(async () => {
await nextTick();
drawMap();
window.addEventListener('resize', handleResize);
});
onBeforeUnmount(() => {
window.removeEventListener('resize', handleResize);
});
</script>
<style scoped>
.map-container {
position: relative;
width: 100%;
height: 400px;
}
.pin {
fill: red;
stroke: black;
stroke-width: 1px;
}
.path {
fill: #ccc;
stroke: #333;
}
.connection-line {
stroke: blue;
stroke-width: 1;
stroke-opacity: 0.5;
}
.popup {
background-color: white;
border: 1px solid black;
padding: 5px;
position: absolute;
display: none;
z-index: 10; /* Ensure the popup is above other elements */
pointer-events: none; /* Prevent mouse events on the popup */
}
</style>

View File

@ -222,7 +222,6 @@ switch (data.received) {
stateStore.arq_is_receiving = false;
switch (data["arq-transfer-outbound"].state) {
case "NEW":
message = `
<div>
<strong>New transmission with:</strong>
@ -423,7 +422,7 @@ switch (data.received) {
return;
case "BURST_REPLY_SENT":
message = `
message = `
<div>
<strong>ongoing transmission with:</strong>
<span class="badge bg-info text-dark">${data["arq-transfer-inbound"].dxcall}</span>
@ -433,7 +432,9 @@ message = `
<span class="badge bg-warning text-dark">Total Bytes: ${data["arq-transfer-outbound"].total_bytes}</span>
</div>
</div>
`; displayToast("info", "bi-info-circle", message, 5000);
`;
displayToast("info", "bi-info-circle", message, 5000);
stateStore.arq_transmission_percent = Math.round(
(data["arq-transfer-inbound"].received_bytes /
data["arq-transfer-inbound"].total_bytes) *

View File

@ -62,8 +62,6 @@ const defaultConfig = {
respond_to_cq: false,
enable_callsign_blacklist: false,
callsign_blacklist: [],
},
TCI: {
tci_ip: "127.0.0.1",
@ -88,15 +86,15 @@ export const settingsStore = reactive({ ...defaultConfig, local: localConfig });
// Function to handle remote configuration changes
export function onChange() {
let remote_config = settingsStore.remote
let remote_config = settingsStore.remote;
let blacklistContent = remote_config.STATION.callsign_blacklist;
// Check if the content is a string
if (typeof blacklistContent === 'string') {
// Check if the content is a string
if (typeof blacklistContent === "string") {
// Split the string by newlines to create an array
blacklistContent = blacklistContent
.split('\n') // Split text by newlines
.split("\n") // Split text by newlines
.map((item) => item.trim()) // Trim whitespace from each line
.filter((item) => item !== ''); // Remove empty lines
.filter((item) => item !== ""); // Remove empty lines
// Update the settings store with the validated array
remote_config.STATION.callsign_blacklist = blacklistContent;
@ -108,10 +106,10 @@ export function onChange() {
remote_config.STATION.callsign_blacklist = [];
}
setConfig(remote_config).then((conf) => {
settingsStore.remote = conf;
settingsStore.remote.STATION.callsign_blacklist = conf.STATION.callsign_blacklist.join('\n');
settingsStore.remote.STATION.callsign_blacklist =
conf.STATION.callsign_blacklist.join("\n");
});
}
@ -120,9 +118,9 @@ export function getRemote() {
return getConfig().then((conf) => {
if (conf !== undefined) {
settingsStore.remote = conf;
settingsStore.remote.STATION.callsign_blacklist = conf.STATION.callsign_blacklist.join('\n');
settingsStore.remote.STATION.callsign_blacklist =
conf.STATION.callsign_blacklist.join("\n");
onChange();
} else {
console.warn("Received undefined configuration, using default!");
settingsStore.remote = defaultConfig.remote;

View File

@ -119,5 +119,3 @@ https://stackoverflow.com/a/9622873
[data-bs-theme="dark"] {
/* default dark theme mods */
}