Add marker clustering and responsive full-width layout

- Integrate Leaflet.markercluster for map performance with large datasets
- Add brand-colored cluster markers (small/medium/large sizes)
- Reduce individual pin size to 17x22px
- Implement LayoutCalculator for dynamic content centering
- Full-width property archive with constrained filters/hero
- Map max 33% width, cards exactly 400px each
- JS calculates optimal column count and sets CSS custom properties

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Hanson.xyz Dev
2025-12-15 22:59:57 -06:00
parent fc018ca604
commit 198c9b9091
5 changed files with 291 additions and 24 deletions
@@ -156,8 +156,13 @@ if (function_exists('mls_get_properties')) {
?> ?>
<!-- Leaflet CSS --> <!-- Leaflet CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin=""/> <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin=""/>
<!-- Leaflet MarkerCluster CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.4.1/dist/MarkerCluster.css" crossorigin=""/>
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.4.1/dist/MarkerCluster.Default.css" crossorigin=""/>
<!-- Leaflet JS --> <!-- Leaflet JS -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script> <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<!-- Leaflet MarkerCluster JS -->
<script src="https://unpkg.com/leaflet.markercluster@1.4.1/dist/leaflet.markercluster.js" crossorigin=""></script>
<script> <script>
var homeprozMapData = { var homeprozMapData = {
properties: <?php echo json_encode($markers); ?>, properties: <?php echo json_encode($markers); ?>,
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -15,7 +15,7 @@
var PropertyMap = { var PropertyMap = {
map: null, map: null,
markers: {}, // Object keyed by property ID markers: {}, // Object keyed by property ID
markerLayer: null, markerCluster: null, // MarkerClusterGroup
selectedPropertyId: null, selectedPropertyId: null,
hoveredPropertyId: null, hoveredPropertyId: null,
baseZIndex: 400, baseZIndex: 400,
@@ -29,16 +29,40 @@
return; return;
} }
// Initialize map centered on Albert Lea area // Initialize map centered on Minnesota
this.map = L.map('property-map').setView([43.6480, -93.3685], 10); this.map = L.map('property-map').setView([45.0, -93.5], 7);
// Add OpenStreetMap tiles // Add OpenStreetMap tiles
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>' attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
}).addTo(this.map); }).addTo(this.map);
// Create a layer group for markers // Create marker cluster group
this.markerLayer = L.layerGroup().addTo(this.map); this.markerCluster = L.markerClusterGroup({
maxClusterRadius: 50,
spiderfyOnMaxZoom: true,
showCoverageOnHover: false,
zoomToBoundsOnClick: true,
disableClusteringAtZoom: 15,
chunkedLoading: true,
chunkInterval: 200,
chunkDelay: 50,
iconCreateFunction: function(cluster) {
var count = cluster.getChildCount();
var size = 'small';
if (count >= 100) {
size = 'large';
} else if (count >= 10) {
size = 'medium';
}
return L.divIcon({
html: '<div><span>' + count + '</span></div>',
className: 'marker-cluster marker-cluster-' + size,
iconSize: L.point(40, 40)
});
}
});
this.map.addLayer(this.markerCluster);
// Add initial markers // Add initial markers
if (initialProperties && initialProperties.length > 0) { if (initialProperties && initialProperties.length > 0) {
@@ -51,15 +75,16 @@
/** /**
* Create marker icon with specified color * Create marker icon with specified color
* Pin size: 16.5x22 (aspect ratio 0.75)
*/ */
createIcon: function(color) { createIcon: function(color) {
color = color || 'red'; color = color || 'red';
return L.divIcon({ return L.divIcon({
className: 'property-marker property-marker-' + color, className: 'property-marker property-marker-' + color,
html: '<div class="marker-pin"></div>', html: '<div class="marker-pin"></div>',
iconSize: [30, 40], iconSize: [17, 22],
iconAnchor: [15, 40], iconAnchor: [8, 22],
popupAnchor: [0, -40] popupAnchor: [0, -22]
}); });
}, },
@@ -67,12 +92,12 @@
* Update map markers with new property data * Update map markers with new property data
*/ */
updateMarkers: function(properties) { updateMarkers: function(properties) {
if (!this.map || !this.markerLayer) { if (!this.map || !this.markerCluster) {
return; return;
} }
// Clear existing markers and reset state // Clear existing markers and reset state
this.markerLayer.clearLayers(); this.markerCluster.clearLayers();
this.markers = {}; this.markers = {};
this.selectedPropertyId = null; this.selectedPropertyId = null;
this.hoveredPropertyId = null; this.hoveredPropertyId = null;
@@ -81,6 +106,8 @@
$('.property-card').removeClass('property-card-highlighted'); $('.property-card').removeClass('property-card-highlighted');
var self = this; var self = this;
var markersToAdd = [];
properties.forEach(function(prop, index) { properties.forEach(function(prop, index) {
if (prop.lat && prop.lng) { if (prop.lat && prop.lng) {
var marker = L.marker([prop.lat, prop.lng], { var marker = L.marker([prop.lat, prop.lng], {
@@ -106,11 +133,14 @@
self.onMarkerClick(prop.id); self.onMarkerClick(prop.id);
}); });
self.markerLayer.addLayer(marker); markersToAdd.push(marker);
self.markers[prop.id] = marker; self.markers[prop.id] = marker;
} }
}); });
// Bulk add markers for performance
this.markerCluster.addLayers(markersToAdd);
// Fit bounds to show all markers // Fit bounds to show all markers
this.fitBounds(properties); this.fitBounds(properties);
}, },
@@ -405,6 +435,11 @@
PropertyMap.updateMarkers(response.data.markers); PropertyMap.updateMarkers(response.data.markers);
} }
// Recalculate layout after content update
if (typeof LayoutCalculator !== 'undefined') {
LayoutCalculator.calculate();
}
// Update URL // Update URL
if (updateHistory) { if (updateHistory) {
self.updateUrl(formData, page); self.updateUrl(formData, page);
@@ -557,6 +592,13 @@
} else { } else {
$main.removeClass('is-map-view').addClass('is-grid-view'); $main.removeClass('is-map-view').addClass('is-grid-view');
} }
// Recalculate layout after view change
if (typeof LayoutCalculator !== 'undefined') {
setTimeout(function() {
LayoutCalculator.calculate();
}, 150);
}
} }
// No need to do anything when crossing below - CSS handles hiding map // No need to do anything when crossing below - CSS handles hiding map
}, },
@@ -566,10 +608,124 @@
} }
}; };
/**
* Layout Calculator
* Calculates optimal layout width to center content based on:
* - Card width: 400px
* - Card gap: 24px (1.5rem)
* - Map: 33% of layout (in map view)
* - Map-to-cards gap: 32px (2rem)
*/
var LayoutCalculator = {
cardWidth: 400,
cardGap: 24,
mapGap: 32,
mapRatio: 0.33,
breakpoint: 1024,
containerPadding: 24, // var(--container-padding)
init: function() {
this.calculate();
var self = this;
var resizeTimeout;
$(window).on('resize', function() {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(function() {
self.calculate();
}, 100);
});
},
calculate: function() {
if (window.innerWidth < this.breakpoint) {
// Below breakpoint, clear custom properties
this.clearProperties();
return;
}
var $main = $('.property-archive-main');
var $container = $main.find('> .container');
var isMapView = $main.hasClass('is-map-view');
// Available width = viewport - container padding
var availableWidth = $container.width();
if (isMapView) {
this.calculateMapLayout(availableWidth);
} else {
this.calculateGridLayout(availableWidth);
}
},
calculateMapLayout: function(availableWidth) {
// In map view: map takes 33% max, rest is for cards
// Layout = mapWidth + mapGap + cardsWidth
// mapWidth = 33% of layout
// cardsWidth = cardColumns * cardWidth + (cardColumns - 1) * cardGap
// Start with max columns that could fit, work down
for (var cols = 5; cols >= 1; cols--) {
var cardsWidth = (cols * this.cardWidth) + ((cols - 1) * this.cardGap);
// mapWidth = 0.33 * layoutWidth, so:
// layoutWidth = mapWidth + mapGap + cardsWidth
// layoutWidth = 0.33 * layoutWidth + mapGap + cardsWidth
// 0.67 * layoutWidth = mapGap + cardsWidth
// layoutWidth = (mapGap + cardsWidth) / 0.67
var layoutWidth = (this.mapGap + cardsWidth) / (1 - this.mapRatio);
if (layoutWidth <= availableWidth) {
this.setProperties(layoutWidth, cols, '.property-map-layout');
this.setProperties(layoutWidth, cols, '.property-list-container');
return;
}
}
// Fallback: 1 column
var cardsWidth = this.cardWidth;
var layoutWidth = (this.mapGap + cardsWidth) / (1 - this.mapRatio);
this.setProperties(Math.min(layoutWidth, availableWidth), 1, '.property-map-layout');
this.setProperties(Math.min(layoutWidth, availableWidth), 1, '.property-list-container');
},
calculateGridLayout: function(availableWidth) {
// In grid view: just cards
// layoutWidth = cardColumns * cardWidth + (cardColumns - 1) * cardGap
for (var cols = 6; cols >= 1; cols--) {
var layoutWidth = (cols * this.cardWidth) + ((cols - 1) * this.cardGap);
if (layoutWidth <= availableWidth) {
this.setProperties(layoutWidth, cols, '.grid-view-container');
return;
}
}
// Fallback: 1 column
this.setProperties(this.cardWidth, 1, '.grid-view-container');
},
setProperties: function(width, columns, selector) {
var $el = $(selector);
if ($el.length) {
$el.css('--layout-width', width + 'px');
$el.css('--card-columns', columns);
}
},
clearProperties: function() {
$('.property-map-layout, .grid-view-container, .property-list-container').css({
'--layout-width': '',
'--card-columns': ''
});
}
};
// Initialize on document ready // Initialize on document ready
$(function() { $(function() {
PropertyFilters.init(); PropertyFilters.init();
ResponsiveView.init(); ResponsiveView.init();
LayoutCalculator.init();
}); });
})(jQuery); })(jQuery);
@@ -103,6 +103,12 @@
// Responsive View Containers // Responsive View Containers
// Below 1024px: always show grid, hide map layout and view toggle // Below 1024px: always show grid, hide map layout and view toggle
// Above 1024px: show based on view selection // Above 1024px: show based on view selection
//
// Layout strategy for wide screens (JS-controlled via CSS custom properties):
// - Map: max 33% of layout width
// - Property cards: exactly 400px each
// - JS calculates optimal width and sets --layout-width custom property
// - Content centered with auto margins
.property-map-layout { .property-map-layout {
display: none; // Hidden by default (mobile-first) display: none; // Hidden by default (mobile-first)
@@ -111,8 +117,13 @@
// Show map layout when in map view above breakpoint // Show map layout when in map view above breakpoint
.is-map-view & { .is-map-view & {
display: grid; display: grid;
grid-template-columns: 38% 1fr; grid-template-columns: minmax(300px, 33%) 1fr;
gap: 2rem; gap: 2rem;
// Width controlled by JS via custom property, fallback to 100%
width: var(--layout-width, 100%);
max-width: 100%;
margin-left: auto;
margin-right: auto;
} }
} }
} }
@@ -135,6 +146,11 @@
.is-grid-view & { .is-grid-view & {
display: block; display: block;
// Width controlled by JS via custom property, fallback to 100%
width: var(--layout-width, 100%);
max-width: 100%;
margin-left: auto;
margin-right: auto;
} }
} }
} }
@@ -181,32 +197,43 @@
} }
@media (min-width: 1024px) { @media (min-width: 1024px) {
grid-template-columns: repeat(2, 1fr); // JS sets --card-columns, fallback to 2
grid-template-columns: repeat(var(--card-columns, 2), 400px);
gap: 1.5rem;
} }
} }
} }
// Custom Map Marker // Grid view properties grid
.grid-view-container .properties-grid {
@media (min-width: 1024px) {
// JS sets --card-columns, fallback to 3
grid-template-columns: repeat(var(--card-columns, 3), 400px);
gap: 1.5rem;
}
}
// Custom Map Marker (17x22px, aspect ratio 0.75)
.property-marker { .property-marker {
background: transparent; background: transparent;
.marker-pin { .marker-pin {
width: 30px; width: 16px;
height: 30px; height: 16px;
border-radius: 50% 50% 50% 0; border-radius: 50% 50% 50% 0;
background: var(--color-accent); background: var(--color-accent);
position: absolute; position: absolute;
transform: rotate(-45deg); transform: rotate(-45deg);
left: 50%; left: 50%;
top: 50%; top: 50%;
margin: -20px 0 0 -15px; margin: -11px 0 0 -8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
&::after { &::after {
content: ''; content: '';
width: 14px; width: 7px;
height: 14px; height: 7px;
margin: 8px 0 0 8px; margin: 4px 0 0 4px;
background: var(--color-bg-dark); background: var(--color-bg-dark);
position: absolute; position: absolute;
border-radius: 50%; border-radius: 50%;
@@ -224,6 +251,70 @@
} }
} }
// Marker Cluster Styling
.marker-cluster {
background-clip: padding-box;
border-radius: 50%;
div {
width: 32px;
height: 32px;
margin-left: 4px;
margin-top: 4px;
text-align: center;
border-radius: 50%;
font-weight: 600;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
}
span {
line-height: 1;
}
}
.marker-cluster-small {
background-color: rgba(181, 126, 99, 0.6);
div {
background-color: rgba(181, 126, 99, 0.9);
color: #000;
font-weight: 800;
}
}
.marker-cluster-medium {
background-color: rgba(181, 126, 99, 0.7);
div {
background-color: rgba(181, 126, 99, 0.95);
color: #000;
font-weight: 800;
width: 36px;
height: 36px;
margin-left: 2px;
margin-top: 2px;
font-size: 13px;
}
}
.marker-cluster-large {
background-color: rgba(181, 126, 99, 0.8);
div {
background-color: rgba(181, 126, 99, 1);
color: #000;
font-weight: 800;
width: 40px;
height: 40px;
margin-left: 0;
margin-top: 0;
font-size: 14px;
}
}
// Highlighted property card (amber border) // Highlighted property card (amber border)
.property-card-highlighted { .property-card-highlighted {
border-color: #F59E0B !important; border-color: #F59E0B !important;
@@ -267,9 +358,24 @@
margin-top: -1px; margin-top: -1px;
} }
// Property Archive Layout // Property Archive Layout - Dynamic width based on content
.property-archive-main > .container { .property-archive-main > .container {
max-width: none;
padding-top: 2rem; padding-top: 2rem;
padding-left: var(--container-padding);
padding-right: var(--container-padding);
}
// Keep the hero section constrained for readability
.property-archive-main .archive-hero .container {
max-width: var(--container-max);
}
// Keep filters constrained
.property-archive-main .property-filters {
max-width: var(--container-max);
margin-left: auto;
margin-right: auto;
} }
// Filters Container // Filters Container