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:
@@ -156,8 +156,13 @@ if (function_exists('mls_get_properties')) {
|
||||
?>
|
||||
<!-- Leaflet CSS -->
|
||||
<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 -->
|
||||
<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>
|
||||
var homeprozMapData = {
|
||||
properties: <?php echo json_encode($markers); ?>,
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
@@ -15,7 +15,7 @@
|
||||
var PropertyMap = {
|
||||
map: null,
|
||||
markers: {}, // Object keyed by property ID
|
||||
markerLayer: null,
|
||||
markerCluster: null, // MarkerClusterGroup
|
||||
selectedPropertyId: null,
|
||||
hoveredPropertyId: null,
|
||||
baseZIndex: 400,
|
||||
@@ -29,16 +29,40 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize map centered on Albert Lea area
|
||||
this.map = L.map('property-map').setView([43.6480, -93.3685], 10);
|
||||
// Initialize map centered on Minnesota
|
||||
this.map = L.map('property-map').setView([45.0, -93.5], 7);
|
||||
|
||||
// Add OpenStreetMap tiles
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
}).addTo(this.map);
|
||||
|
||||
// Create a layer group for markers
|
||||
this.markerLayer = L.layerGroup().addTo(this.map);
|
||||
// Create marker cluster group
|
||||
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
|
||||
if (initialProperties && initialProperties.length > 0) {
|
||||
@@ -51,15 +75,16 @@
|
||||
|
||||
/**
|
||||
* Create marker icon with specified color
|
||||
* Pin size: 16.5x22 (aspect ratio 0.75)
|
||||
*/
|
||||
createIcon: function(color) {
|
||||
color = color || 'red';
|
||||
return L.divIcon({
|
||||
className: 'property-marker property-marker-' + color,
|
||||
html: '<div class="marker-pin"></div>',
|
||||
iconSize: [30, 40],
|
||||
iconAnchor: [15, 40],
|
||||
popupAnchor: [0, -40]
|
||||
iconSize: [17, 22],
|
||||
iconAnchor: [8, 22],
|
||||
popupAnchor: [0, -22]
|
||||
});
|
||||
},
|
||||
|
||||
@@ -67,12 +92,12 @@
|
||||
* Update map markers with new property data
|
||||
*/
|
||||
updateMarkers: function(properties) {
|
||||
if (!this.map || !this.markerLayer) {
|
||||
if (!this.map || !this.markerCluster) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear existing markers and reset state
|
||||
this.markerLayer.clearLayers();
|
||||
this.markerCluster.clearLayers();
|
||||
this.markers = {};
|
||||
this.selectedPropertyId = null;
|
||||
this.hoveredPropertyId = null;
|
||||
@@ -81,6 +106,8 @@
|
||||
$('.property-card').removeClass('property-card-highlighted');
|
||||
|
||||
var self = this;
|
||||
var markersToAdd = [];
|
||||
|
||||
properties.forEach(function(prop, index) {
|
||||
if (prop.lat && prop.lng) {
|
||||
var marker = L.marker([prop.lat, prop.lng], {
|
||||
@@ -106,11 +133,14 @@
|
||||
self.onMarkerClick(prop.id);
|
||||
});
|
||||
|
||||
self.markerLayer.addLayer(marker);
|
||||
markersToAdd.push(marker);
|
||||
self.markers[prop.id] = marker;
|
||||
}
|
||||
});
|
||||
|
||||
// Bulk add markers for performance
|
||||
this.markerCluster.addLayers(markersToAdd);
|
||||
|
||||
// Fit bounds to show all markers
|
||||
this.fitBounds(properties);
|
||||
},
|
||||
@@ -405,6 +435,11 @@
|
||||
PropertyMap.updateMarkers(response.data.markers);
|
||||
}
|
||||
|
||||
// Recalculate layout after content update
|
||||
if (typeof LayoutCalculator !== 'undefined') {
|
||||
LayoutCalculator.calculate();
|
||||
}
|
||||
|
||||
// Update URL
|
||||
if (updateHistory) {
|
||||
self.updateUrl(formData, page);
|
||||
@@ -557,6 +592,13 @@
|
||||
} else {
|
||||
$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
|
||||
},
|
||||
@@ -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
|
||||
$(function() {
|
||||
PropertyFilters.init();
|
||||
ResponsiveView.init();
|
||||
LayoutCalculator.init();
|
||||
});
|
||||
|
||||
})(jQuery);
|
||||
|
||||
@@ -103,6 +103,12 @@
|
||||
// Responsive View Containers
|
||||
// Below 1024px: always show grid, hide map layout and view toggle
|
||||
// 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 {
|
||||
display: none; // Hidden by default (mobile-first)
|
||||
@@ -111,8 +117,13 @@
|
||||
// Show map layout when in map view above breakpoint
|
||||
.is-map-view & {
|
||||
display: grid;
|
||||
grid-template-columns: 38% 1fr;
|
||||
grid-template-columns: minmax(300px, 33%) 1fr;
|
||||
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 & {
|
||||
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) {
|
||||
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 {
|
||||
background: transparent;
|
||||
|
||||
.marker-pin {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50% 50% 50% 0;
|
||||
background: var(--color-accent);
|
||||
position: absolute;
|
||||
transform: rotate(-45deg);
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
margin: -20px 0 0 -15px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
margin: -11px 0 0 -8px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin: 8px 0 0 8px;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
margin: 4px 0 0 4px;
|
||||
background: var(--color-bg-dark);
|
||||
position: absolute;
|
||||
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)
|
||||
.property-card-highlighted {
|
||||
border-color: #F59E0B !important;
|
||||
@@ -267,9 +358,24 @@
|
||||
margin-top: -1px;
|
||||
}
|
||||
|
||||
// Property Archive Layout
|
||||
// Property Archive Layout - Dynamic width based on content
|
||||
.property-archive-main > .container {
|
||||
max-width: none;
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user