added new adbrimofcp map

This commit is contained in:
2025-09-16 11:51:25 +02:00
parent e79b2392c9
commit 5db3a64e34
7 changed files with 445 additions and 25 deletions

View File

@@ -48,9 +48,12 @@
<th>Extref</th>
<td><?=$address->extref?></td>
</tr><tr>
<th>Rimo External ID</th>
<td><?=$address->rimo_id?></td>
</tr><tr>
<th>Rimo External ID</th>
<td><?=$address->rimo_id?></td>
</tr><tr>
<th>Rimo Type</th>
<td><?=$address->rimo_type?></td>
</tr><tr>
<th>Netzgebiet</th>
<td><?=$address->netzgebiet->name?></td>
</tr><tr>

View File

@@ -24,7 +24,7 @@ $pagination_entity_name = "Vorbestellungen";
}
.preorder-campaign-header-buttons {
max-width: 900px;
max-width: 1100px;
}
.tr-highlight {
@@ -458,6 +458,8 @@ $pagination_entity_name = "Vorbestellungen";
</ul>
</div>
<a id="rimo-types-link" target="_blank" style="display:none" href="#" class="btn btn-outline-success"><i class="fas fa-map-marked-alt"></i>Rimo-Typen Karte anzeigen</a>
</div>
</div>
</form>
@@ -1992,6 +1994,21 @@ $pagination_entity_name = "Vorbestellungen";
});
});
campaignSelect.trigger("change");
// for the Rimo-Typen Karte <a> only show this <a> button if a preordercampaign is selected and change the display and href dynamically
const rimoTypesLink = $("#rimo-types-link");
function updateRimoTypesLink() {
const campaignId = campaignSelect.val();
if (campaignId) {
rimoTypesLink.show();
rimoTypesLink.attr("href", "<?=self::getUrl("Preorder", "RimoTypeMap")?>?preordercampaign_id=" + campaignId);
} else {
rimoTypesLink.hide();
rimoTypesLink.attr("href", "#");
}
}
campaignSelect.on("change", updateRimoTypesLink);
updateRimoTypesLink();
});
</script>
<?php include(realpath(dirname(__FILE__)."/../../$mfLayoutPackage")."/footer.php"); ?>

View File

@@ -1788,4 +1788,26 @@ class PreorderController extends mfBaseController {
$this->layout()->set("JSGlobals", $JSGlobals);
$this->layout()->setTemplate("VueViews/Vue"); // Assuming a generic Vue template
}
public function RimoTypeMapAction() {
$allowedCampaigns = Helper::getPreorderCampaignFromUser($this->me);
$campaignId = $this->request->preordercampaign_id ?? null;
if (!$campaignId || !in_array($campaignId, $allowedCampaigns)) {
$this->layout()->setFlash("Ungültige oder keine Kampagne ausgewählt.", "warning");
$this->redirect("Preorder", "Index");
}
Helper::renderVue($this, "PreorderRimoTypeMap", "PreorderRimoTypeMap", ["MAPBOX_KEY" => TT_MAPBOX_TILE_API_TOKEN]);
}
public function RimoTypeMapDataAction() {
$input = json_decode(file_get_contents('php://input'), true);
$campaignId = $input['campaignId'] ?? null;
$allowedCampaigns = Helper::getPreorderCampaignFromUser($this->me);
if (!$campaignId || !in_array($campaignId, $allowedCampaigns)) self::sendError('Ungültige oder keine Kampagne ausgewählt.');
$data = PreorderModel::getPreorderRimoTypeData($campaignId);
self::returnJson(['success' => true, 'data' => $data]);
}
}

View File

@@ -1230,7 +1230,7 @@ class PreorderModel
public static function countTotalUnits($preorderCampaignId = null) {
$db = FronkDB::singleton();
$where = "1=1";
$where = " h.rimo_type != 'greenfield' ";
if ($preorderCampaignId) {
$where .= " AND pc.id = $preorderCampaignId";
}
@@ -1348,5 +1348,35 @@ ORDER BY
return $items;
}
public static function getPreorderRimoTypeData(int $campaignId): array {
$db = FronkDB::singleton(ADDRESSDB_DBHOST, ADDRESSDB_DBUSER, ADDRESSDB_DBPASS, ADDRESSDB_DBNAME);
$fronkDbName = defined('FRONKDB_DBNAME') ? FRONKDB_DBNAME : 'thetool';
$addressDbName = defined('ADDRESSDB_DBNAME') ? ADDRESSDB_DBNAME : 'addressdb';
$safeCampaignId = (int)$campaignId;
$sql = "
SELECT
h.id AS hausnummer_id, h.gps_lat, h.gps_long, h.rimo_type, h.rimo_op_state, h.rimo_ex_state, h.hausnummer,
s.name AS strasse_name, plz.plz AS plz_name, o.name AS ortschaft_name,
COUNT(DISTINCT we.id) AS wohneinheit_count,
COUNT(DISTINCT pr.id) AS preorder_count
FROM `{$addressDbName}`.`Hausnummer` AS h
LEFT JOIN `{$addressDbName}`.`Wohneinheit` AS we ON h.id = we.hausnummer_id
LEFT JOIN `{$fronkDbName}`.`Preorder` AS pr ON we.id = pr.adb_wohneinheit_id AND pr.preordercampaign_id = {$safeCampaignId} AND pr.deleted = 0
LEFT JOIN `{$fronkDbName}`.`Preorderstatus` AS ps ON pr.status_id = ps.id AND ps.code < 899
LEFT JOIN `{$addressDbName}`.`Strasse` AS s ON h.strasse_id = s.id
LEFT JOIN `{$addressDbName}`.`Plz` AS plz ON h.plz_id = plz.id
LEFT JOIN `{$addressDbName}`.`Ortschaft` AS o ON h.ortschaft_id = o.id
WHERE h.netzgebiet_id = (
SELECT n.adb_netzgebiet_id FROM `{$fronkDbName}`.`Preordercampaign` pc
JOIN `{$fronkDbName}`.`Network` n ON pc.network_id = n.id
WHERE pc.id = {$safeCampaignId}
) AND h.gps_lat IS NOT NULL AND h.gps_long IS NOT NULL
GROUP BY h.id
ORDER BY h.id
";
$result = $db->query($sql);
return $result ? $result->fetch_all(MYSQLI_ASSOC) : [];
}
}

View File

@@ -0,0 +1,89 @@
.preorder-map-container {
height: 100%; /* Changed from 85vh to fill parent */
width: 100%;
}
.map-filter-container {
display: flex;
gap: 0.5rem;
padding: 0.5rem 1rem;
background-color: #f8f9fa;
border-bottom: 1px solid #dee2e6;
flex-wrap: wrap;
}
.marker-label {
/* This is now the outer, transparent container provided by Leaflet. */
/* We remove its background and border to let the inner div's style show through. */
background: transparent !important;
border: none !important;
box-shadow: none !important;
padding: 0 !important;
/* These properties are for Leaflet's positioning and events */
z-index: 1000;
pointer-events: none;
transition: visibility 0.2s, opacity 0.2s linear;
}
/* Base style for the new inner div that holds the content */
.tooltip-content-wrapper {
background-color: rgba(255, 255, 255, 0.85);
border: 1px solid #ccc;
border-radius: 5px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
color: #333;
font-size: 12px;
font-weight: bold;
padding: 2px 5px;
text-align: center;
white-space: nowrap;
}
/* Style for greenfield markers with at least one preorder */
.tooltip-content-wrapper.marker-label-highlight {
background-color: rgba(248, 215, 218, 0.9); /* Soft red */
border-color: #e57373;
color: #721c24;
}
/* Style for markers where preorders match housing units */
.tooltip-content-wrapper.marker-label-saturated {
background-color: rgba(212, 237, 218, 0.9); /* Light green */
border-color: #81c784;
color: #155724;
}
/* * This rule is now more specific to ensure it overrides Leaflet's default styles.
* It targets the DIV element that has BOTH .leaflet-marker-icon AND .custom-div-icon classes.
*/
div.leaflet-marker-icon.custom-div-icon {
background: transparent !important;
border: none !important;
}
/* Base style for all markers */
.rimo-marker {
display: flex;
justify-content: center;
align-items: center;
width: 30px;
height: 30px;
border-radius: 50%;
border: 2px solid white;
box-shadow: 0 0 5px rgba(0, 0, 0, 0.5);
clip-path: inset(0 round 50%);
}
.rimo-icon {
font-size: 16px;
color: white;
}
/* Specific styles for each rimo_type */
.marker-greenfield { background-color: #28a745; }
.marker-residential { background-color: #007bff; }
.marker-company { background-color: #ffc107; }
.marker-multiple-dwelling { background-color: #6f42c1; }
.marker-public { background-color: #17a2b8; }
.marker-other { background-color: #6c757d; }

View File

@@ -0,0 +1,236 @@
Vue.component('PreorderRimoTypeMap', {
data() {
return {
mapMarkers: [],
isLoading: false,
window,
fetchUrl: window.TT_CONFIG.BASE_PATH + '/Preorder/RimoTypeMapData',
selectedCampaign: null,
mapConfig: {
clusterOptions: {
spiderfyOnMaxZoom: false,
disableClusteringAtZoom: 17,
}
},
mapInstance: null, // Reference to the map instance
activeFilters: [], // To store active rimo_type filters
filterOptions: [
{ value: 'greenfield', text: 'Greenfield', icon: 'fas fa-tree' },
{ value: 'residential', text: 'Wohngebiet', icon: 'fas fa-home' },
{ value: 'company', text: 'Gewerbe', icon: 'fas fa-building' },
{ value: 'multiple-dwelling', text: 'Mehrfamilienhaus', icon: 'fas fa-city' },
{ value: 'public', text: 'Öffentlich', icon: 'fas fa-school' },
{ value: 'other', text: 'Andere', icon: 'fas fa-question-circle' }
]
};
},
computed: {
/**
* Filters markers based on the activeFilters array.
* If no filters are active, all markers are shown.
*/
filteredMapMarkers() {
if (this.activeFilters.length === 0) {
return this.mapMarkers;
}
return this.mapMarkers.filter(marker => this.activeFilters.includes(marker.rimoType));
}
},
async created() {
const urlParams = new URLSearchParams(window.location.search);
this.selectedCampaign = urlParams.get('preordercampaign_id');
if (this.selectedCampaign) {
await this.fetchAndPrepareData();
}
},
mounted() {
this.$nextTick(() => {
const ttMapComponent = this.$refs.ttMap;
if (ttMapComponent && ttMapComponent.map) {
this.mapInstance = ttMapComponent.map;
this.mapInstance.on('zoomend', this.checkZoomLevel);
this.checkZoomLevel();
}
});
},
beforeDestroy() {
if (this.mapInstance) {
this.mapInstance.off('zoomend', this.checkZoomLevel);
}
},
methods: {
async fetchAndPrepareData() {
if (!this.selectedCampaign) return;
this.isLoading = true;
this.mapMarkers = [];
try {
const response = await axios.post(this.fetchUrl, { campaignId: this.selectedCampaign });
if (response.data.success && Array.isArray(response.data.data)) {
this.mapMarkers = this.processData(response.data.data);
} else {
window.notify('error', 'Ungültiges Datenformat von der API empfangen.');
}
} catch (err) {
window.notify('error', 'Laden der Kartendaten fehlgeschlagen.');
} finally {
this.isLoading = false;
}
},
processData(data) {
const groupedData = {};
data.forEach(item => {
const latLngKey = `${item.gps_lat},${item.gps_long}`;
if (!groupedData[latLngKey]) {
groupedData[latLngKey] = {
...item,
wohneinheit_count: parseInt(item.wohneinheit_count, 10),
preorder_count: parseInt(item.preorder_count, 10),
original_items: [item],
};
} else {
groupedData[latLngKey].wohneinheit_count += parseInt(item.wohneinheit_count, 10);
groupedData[latLngKey].preorder_count += parseInt(item.preorder_count, 10);
groupedData[latLngKey].original_items.push(item);
}
});
return Object.values(groupedData).map(group => {
const rimoType = this.getNormalizedRimoType(group.rimo_type);
const markerIcon = this.getMarkerIcon(rimoType);
let tooltipInnerClass = '';
if (rimoType !== 'greenfield' && group.wohneinheit_count > 0 && group.wohneinheit_count === group.preorder_count) {
tooltipInnerClass = ' marker-label-saturated';
} else if (rimoType === 'greenfield' && group.preorder_count > 0) {
tooltipInnerClass = ' marker-label-highlight';
}
return {
lat: group.gps_lat,
lng: group.gps_long,
rimoType: rimoType, // Add normalized rimoType for filtering
options: {
icon: {
className: `custom-div-icon marker-${rimoType}`,
html: `<div class="rimo-marker ${markerIcon.class}"><i class="${markerIcon.icon} rimo-icon"></i></div>`,
iconSize: [30, 30],
iconAnchor: [15, 30],
},
tooltip: {
content: `<div class="tooltip-content-wrapper${tooltipInnerClass}">H: ${group.wohneinheit_count}<br>B: ${group.preorder_count}</div>`,
direction: 'bottom',
className: 'marker-label',
permanent: true,
},
asyncPopupContent: async () => {
let content = `<div style="font-size: 0.85rem;">`;
group.original_items.forEach(item => {
content += `
<div class="mb-2">
<h5 class="mb-2 mt-1">${item.strasse_name} ${item.hausnummer}, ${item.plz_name} ${item.ortschaft_name}</h5>
<strong>Rimo Type:</strong> ${item.rimo_type || 'N/A'}<br>
<strong>Rimo Op State:</strong> ${item.rimo_op_state || 'N/A'}<br>
<strong>Rimo Ex State:</strong> ${item.rimo_ex_state || 'N/A'}<br>
<strong>Wohn. gesamt:</strong> ${item.wohneinheit_count}<br>
<strong>Bestellungen:</strong> ${item.preorder_count}<br>
<strong>Koordinaten:</strong>
<a href="https://www.google.com/maps/search/?api=1&query=${item.gps_lat},${item.gps_long}" target="_blank" class="text-primary">
<i class="fas fa-map-marker-alt mr-1"></i>Karte
</a>
<a href="https://thetool.xinon.at/AddressDB/View?id=${item.hausnummer_id}" target="_blank" class="text-primary ml-2">
<i class="fas fa-info-circle mr-1"></i>AddressDB
</a>
</div><hr class="my-1">`;
});
return content.slice(0, -16) + `</div>`; // Remove last <hr>
},
},
};
});
},
getNormalizedRimoType(type) {
const lowerType = (type || '').toLowerCase();
if (lowerType.includes('greenfield')) return 'greenfield';
if (lowerType.includes('residential')) return 'residential';
if (lowerType.includes('multiple dwelling')) return 'multiple-dwelling';
if (lowerType.includes('company') || lowerType.includes('commercial')) return 'company';
if (lowerType.includes('public') || lowerType.includes('school')) return 'public';
return 'other';
},
getMarkerIcon(rimoType) {
const icons = {
greenfield: { class: 'marker-greenfield', icon: 'fas fa-tree' },
residential: { class: 'marker-residential', icon: 'fas fa-home' },
company: { class: 'marker-company', icon: 'fas fa-building' },
'multiple-dwelling': { class: 'marker-multiple-dwelling', icon: 'fas fa-city' },
public: { class: 'marker-public', icon: 'fas fa-school' },
other: { class: 'marker-other', icon: 'fas fa-question-circle' }
};
return icons[rimoType] || icons.other;
},
checkZoomLevel() {
if (!this.mapInstance) return;
const currentZoom = this.mapInstance.getZoom();
const minZoomForLabel = 16;
const visibility = currentZoom >= minZoomForLabel ? 'visible' : 'hidden';
const opacity = currentZoom >= minZoomForLabel ? '1' : '0';
document.querySelectorAll('.marker-label').forEach(el => {
el.style.visibility = visibility;
el.style.opacity = opacity;
});
},
/**
* Toggles a filter on or off.
* @param {string} filterValue - The rimo_type to toggle.
*/
toggleFilter(filterValue) {
const index = this.activeFilters.indexOf(filterValue);
if (index > -1) {
this.activeFilters.splice(index, 1);
} else {
this.activeFilters.push(filterValue);
}
},
/**
* Checks if a filter is currently active.
* @param {string} filterValue - The rimo_type to check.
* @returns {boolean}
*/
isFilterActive(filterValue) {
return this.activeFilters.includes(filterValue);
}
},
template: `
<tt-card style="height: 75vh; position: relative; display: flex; flex-direction: column;">
<div v-if="!selectedCampaign" class="alert alert-warning m-3">
Bitte eine Kampagne über den URL-Parameter 'preordercampaign_id' auswählen (z.B. ?preordercampaign_id=44).
</div>
<template v-else>
<div class="map-filter-container">
<button v-for="filter in filterOptions"
:key="filter.value"
@click="toggleFilter(filter.value)"
:class="['btn', 'btn-sm', isFilterActive(filter.value) ? 'btn-primary' : 'btn-outline-secondary']"
:title="filter.text">
<i :class="filter.icon"></i>
</button>
</div>
<div style="height: 100%; position: relative; flex-grow: 1;">
<div v-if="!isLoading && mapMarkers.length === 0" class="alert alert-info m-3">
Keine Standorte für die ausgewählte Kampagne gefunden.
</div>
<tt-map ref="ttMap" :markers-data="filteredMapMarkers" :loading="isLoading" :config="mapConfig" class="preorder-map-container"></tt-map>
</div>
</template>
</tt-card>
`
});

View File

@@ -2,7 +2,7 @@ Vue.component('tt-map', {
props: {
markersData: {
type: Array,
default: () => [] // Expecting [{ lat: Number, lng: Number, options: { maki?: Object, popup?: String, asyncPopupContent?: Function } }, ...]
default: () => [] // Expecting [{ lat: Number, lng: Number, options: { maki?: Object, popup?: String, asyncPopupContent?: Function, icon?: Object, tooltip?: Object } }, ...]
},
config: {
type: Object,
@@ -35,7 +35,7 @@ Vue.component('tt-map', {
streetsTileUrl: 'https://api.mapbox.com/styles/v1/{id}/tiles/{z}/{x}/{y}?access_token={accessToken}',
streetsTileId: 'mapbox/streets-v11',
satelliteTileUrl: 'https://api.mapbox.com/styles/v1/{id}/tiles/{z}/{x}/{y}?access_token={accessToken}',
satelliteTileId: 'mapbox/satellite-streets-v12', // Or 'mapbox/satellite-v9'
satelliteTileId: 'mapbox/satellite-streets-v12',
tileAttribution: '© <a href="https://www.mapbox.com/about/maps/">Mapbox</a> © <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>',
clusterOptions: {},
makiMarkerOptions: { icon: "marker", color: "#3b82f6", size: "m" }
@@ -73,37 +73,40 @@ Vue.component('tt-map', {
el.src = s.url; el.async = false; el.onload = resolve; el.onerror = reject;
} else {
el = document.createElement('link');
el.rel = 'stylesheet'; el.href = s.url; resolve();
el.rel = 'stylesheet'; el.href = s.url; el.onload = resolve; el.onerror = reject;
}
if (el) document.head.appendChild(el); else reject();
document.head.appendChild(el);
}));
return Promise.all(promises);
},
initializeMap() {
if (!this.scriptsLoaded || !L || !L.MarkerClusterGroup || !L.MakiMarkers || !this.mapConfig.mapboxKey) return;
if (!this.scriptsLoaded || typeof L === 'undefined' || typeof L.MarkerClusterGroup === 'undefined' || !this.mapConfig.mapboxKey) {
console.error("Leaflet or MarkerCluster is not loaded. Cannot initialize map.");
return;
}
this.map = L.map(this.$refs.mapContainer, { preferCanvas: true }).setView(this.mapConfig.center, this.mapConfig.zoom);
L.MakiMarkers.accessToken = this.mapConfig.mapboxKey;
if (typeof L.MakiMarkers !== 'undefined') {
L.MakiMarkers.accessToken = this.mapConfig.mapboxKey;
}
this.tileLayers.streets = L.tileLayer(this.mapConfig.streetsTileUrl, {
attribution: this.mapConfig.tileAttribution, maxZoom: 18, id: this.mapConfig.streetsTileId,
attribution: this.mapConfig.tileAttribution, maxZoom: 20, id: this.mapConfig.streetsTileId,
tileSize: 512, zoomOffset: -1, accessToken: this.mapConfig.mapboxKey
});
this.tileLayers.satellite = L.tileLayer(this.mapConfig.satelliteTileUrl, {
attribution: this.mapConfig.tileAttribution, maxZoom: 18, id: this.mapConfig.satelliteTileId,
attribution: this.mapConfig.tileAttribution, maxZoom: 20, id: this.mapConfig.satelliteTileId,
tileSize: 512, zoomOffset: -1, accessToken: this.mapConfig.mapboxKey
});
this.tileLayers[this.mapType].addTo(this.map); // Add initial layer based on preference
this.tileLayers[this.mapType].addTo(this.map);
this.markerLayer = L.markerClusterGroup(this.mapConfig.clusterOptions);
this.map.addLayer(this.markerLayer);
// Invalidate size after initial load if container might not have been ready
this.$nextTick(() => {
this.map.invalidateSize();
});
// Add resize listener
window.addEventListener('resize', this.handleResize);
},
updateMarkers() {
@@ -112,32 +115,54 @@ Vue.component('tt-map', {
const markersToAdd = [];
this.markersData.forEach(data => {
if (data.lat != null && data.lng != null) {
const makiOptions = { ...this.mapConfig.makiMarkerOptions, ...(data.options?.maki || {}) };
const icon = L.MakiMarkers.icon(makiOptions);
let icon;
if (data.options?.icon instanceof L.Icon) {
icon = data.options.icon;
} else if (data.options?.icon) {
icon = L.divIcon(data.options.icon);
} else if (typeof L.MakiMarkers !== 'undefined') {
const makiOptions = { ...this.mapConfig.makiMarkerOptions, ...(data.options?.maki || {}) };
icon = L.MakiMarkers.icon(makiOptions);
}
const marker = L.marker([data.lat, data.lng], { icon: icon });
if (data.options?.popup) {
marker.bindPopup(data.options.popup);
} else if (data.options?.asyncPopupContent && typeof data.options.asyncPopupContent === 'function') {
marker.bindPopup(() => '<div class="popup-loader">Loading...</div>'); // Initial content
marker.bindPopup(() => '<div class="popup-loader">Loading...</div>');
marker.on('popupopen', async (e) => {
const popup = e.popup;
try {
const content = await data.options.asyncPopupContent(data); // Pass marker data to function
const content = await data.options.asyncPopupContent(data);
popup.setContent(content);
} catch (error) {
console.error("Error loading popup content:", error);
popup.setContent('<div class="text-danger">Failed to load content.</div>');
}
popup.update(); // Adjust size
popup.update();
});
}
if (data.options?.tooltip) marker.bindTooltip(data.options.tooltip);
if (data.options?.tooltip) {
// Check if it's an object with content and options, or just a string
if (typeof data.options.tooltip === 'object') {
const tooltipContent = data.options.tooltip.content;
const tooltipOptions = { ...data.options.tooltip };
delete tooltipOptions.content; // Remove content to prevent conflicts
marker.bindTooltip(tooltipContent, tooltipOptions);
} else {
marker.bindTooltip(data.options.tooltip);
}
}
markersToAdd.push(marker);
}
});
if (markersToAdd.length > 0) this.markerLayer.addLayers(markersToAdd);
if (markersToAdd.length > 0) {
this.markerLayer.addLayers(markersToAdd);
this.map.fitBounds(this.markerLayer.getBounds());
}
},
toggleMapType() {
this.map.removeLayer(this.tileLayers[this.mapType]);
@@ -147,7 +172,6 @@ Vue.component('tt-map', {
},
handleResize() {
if (this.map) {
// Use debounce if resize events fire too rapidly
this.map.invalidateSize();
}
}
@@ -155,7 +179,6 @@ Vue.component('tt-map', {
watch: {
markersData: { handler() { this.updateMarkers(); }, deep: true },
loading(newVal) {
// Optional: Invalidate map size when loading finishes, in case container size changed
if (!newVal && this.map) {
this.$nextTick(() => this.map.invalidateSize());
}