From 5db3a64e346d06b44e50f98b4ae3c26acba89496 Mon Sep 17 00:00:00 2001 From: Luca Haid Date: Tue, 16 Sep 2025 11:51:25 +0200 Subject: [PATCH] added new adbrimofcp map --- Layout/default/AddressDB/View.php | 9 +- Layout/default/Preorder/Index.php | 19 +- application/Preorder/PreorderController.php | 22 ++ application/Preorder/PreorderModel.php | 32 ++- .../PreorderRimoTypeMap.css | 89 +++++++ .../PreorderRimoTypeMap.js | 236 ++++++++++++++++++ public/plugins/vue/tt-components/tt-map.js | 63 +++-- 7 files changed, 445 insertions(+), 25 deletions(-) create mode 100644 public/js/pages/PreorderRimoTypeMap/PreorderRimoTypeMap.css create mode 100644 public/js/pages/PreorderRimoTypeMap/PreorderRimoTypeMap.js diff --git a/Layout/default/AddressDB/View.php b/Layout/default/AddressDB/View.php index 9c2bd6448..ffb737587 100644 --- a/Layout/default/AddressDB/View.php +++ b/Layout/default/AddressDB/View.php @@ -48,9 +48,12 @@ Extref extref?> - Rimo External ID - rimo_id?> - + Rimo External ID + rimo_id?> + + Rimo Type + rimo_type?> + Netzgebiet netzgebiet->name?> diff --git a/Layout/default/Preorder/Index.php b/Layout/default/Preorder/Index.php index f34609970..31c3ab649 100644 --- a/Layout/default/Preorder/Index.php +++ b/Layout/default/Preorder/Index.php @@ -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"; + + @@ -1992,6 +1994,21 @@ $pagination_entity_name = "Vorbestellungen"; }); }); campaignSelect.trigger("change"); + + // for the Rimo-Typen Karte only show this 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", "?preordercampaign_id=" + campaignId); + } else { + rimoTypesLink.hide(); + rimoTypesLink.attr("href", "#"); + } + } + campaignSelect.on("change", updateRimoTypesLink); + updateRimoTypesLink(); }); diff --git a/application/Preorder/PreorderController.php b/application/Preorder/PreorderController.php index b4b735738..6efa1cadb 100644 --- a/application/Preorder/PreorderController.php +++ b/application/Preorder/PreorderController.php @@ -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]); + } } diff --git a/application/Preorder/PreorderModel.php b/application/Preorder/PreorderModel.php index c1c418a55..04ebaa0b0 100644 --- a/application/Preorder/PreorderModel.php +++ b/application/Preorder/PreorderModel.php @@ -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) : []; + } } diff --git a/public/js/pages/PreorderRimoTypeMap/PreorderRimoTypeMap.css b/public/js/pages/PreorderRimoTypeMap/PreorderRimoTypeMap.css new file mode 100644 index 000000000..c3c259938 --- /dev/null +++ b/public/js/pages/PreorderRimoTypeMap/PreorderRimoTypeMap.css @@ -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; } \ No newline at end of file diff --git a/public/js/pages/PreorderRimoTypeMap/PreorderRimoTypeMap.js b/public/js/pages/PreorderRimoTypeMap/PreorderRimoTypeMap.js new file mode 100644 index 000000000..9d8cacc78 --- /dev/null +++ b/public/js/pages/PreorderRimoTypeMap/PreorderRimoTypeMap.js @@ -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: `
`, + iconSize: [30, 30], + iconAnchor: [15, 30], + }, + tooltip: { + content: `
H: ${group.wohneinheit_count}
B: ${group.preorder_count}
`, + direction: 'bottom', + className: 'marker-label', + permanent: true, + }, + asyncPopupContent: async () => { + let content = `
`; + group.original_items.forEach(item => { + content += ` +
`; + }); + return content.slice(0, -16) + `
`; // Remove last
+ }, + }, + }; + }); + }, + + 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: ` + +
+ Bitte eine Kampagne über den URL-Parameter 'preordercampaign_id' auswählen (z.B. ?preordercampaign_id=44). +
+ +
+ ` +}); \ No newline at end of file diff --git a/public/plugins/vue/tt-components/tt-map.js b/public/plugins/vue/tt-components/tt-map.js index 72339ae77..3fdd93aa9 100644 --- a/public/plugins/vue/tt-components/tt-map.js +++ b/public/plugins/vue/tt-components/tt-map.js @@ -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: '© Mapbox © OpenStreetMap', 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(() => ''); // Initial content + marker.bindPopup(() => ''); 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('
Failed to load content.
'); } - 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()); }