Paste one address per line. Optional: Label,Address per line to give each property a custom label.
Examples:
123 Main St, Austin TX
The Grove,500 W 5th St, Austin TX 78701
Groundwork turns census, rent, demographic, and local-market signals into a quick market read your leasing team can use — and a high-value lead magnet for prospects who want to understand their next community, comp set, or lease-up opportunity.
Paste one address per line. Optional: Label,Address per line to give each property a custom label.
Examples:
123 Main St, Austin TX
The Grove,500 W 5th St, Austin TX 78701
estV) { bestV = v; bestI = i; } if (higherIsBetter ? v < worstV : v > worstV) { worstV = v; worstI = i; } }); } body += `
`; } body += ''; document.getElementById('cmp-modal-body').innerHTML = head + body; } function download(content, filename, mime) { const blob = new Blob([content], { type: mime }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 100); } // Capture the current map view as a PNG. Composites the Mapbox WebGL canvas // (which has the choropleth + rings) and overlays HTML pin markers using a // 2D canvas pass. function captureScreenshot() { // Force a redraw before grabbing the buffer map.triggerRepaint(); setTimeout(() => { try { const mapCanvas = map.getCanvas(); const w = mapCanvas.width, h = mapCanvas.height; const out = document.createElement('canvas'); out.width = w; out.height = h; const ctx = out.getContext('2d'); ctx.drawImage(mapCanvas, 0, 0); // Overlay each property pin as a simple marker const dpr = window.devicePixelRatio || 1; properties.forEach(p => { const px = map.project([p.lng, p.lat]); const x = px.x * dpr, y = px.y * dpr; ctx.fillStyle = '#0066FF'; ctx.strokeStyle = '#fff'; ctx.lineWidth = 2 * dpr; ctx.beginPath(); ctx.arc(x, y - 8 * dpr, 8 * dpr, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); // Tail ctx.beginPath(); ctx.moveTo(x - 5 * dpr, y - 4 * dpr); ctx.lineTo(x, y); ctx.lineTo(x + 5 * dpr, y - 4 * dpr); ctx.closePath(); ctx.fillStyle = '#0066FF'; ctx.fill(); ctx.stroke(); }); // Watermark ctx.fillStyle = 'rgba(0,0,102,0.85)'; ctx.font = `${12 * dpr}px Ubuntu, Arial`; ctx.fillText('Lease-Ups Community Intel · ' + new Date().toLocaleDateString(), 12 * dpr, h - 14 * dpr); out.toBlob(b => { const url = URL.createObjectURL(b); const a = document.createElement('a'); a.href = url; a.download = `lease-ups-map_${Date.now()}.png`; document.body.appendChild(a); a.click(); setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 100); }, 'image/png'); } catch (err) { alert('Screenshot failed: ' + err.message); } }, 60); } // ── CSV bulk import ───────────────────────────────────────────────────────── function openCsvModal() { document.getElementById('csv-modal').classList.add('show'); } function closeCsvModal() { document.getElementById('csv-modal').classList.remove('show'); document.getElementById('csv-textarea').value = ''; document.getElementById('csv-progress').textContent = ''; document.getElementById('csv-import-btn').disabled = false; } function parseCsvLine(line) { // Naive split on first comma — anything before is label, rest is address. // Lines without a label are entirely treated as the address. const idx = line.indexOf(','); // Heuristic: if label is short (< 30 chars) and address contains another comma, treat as label,address. // Otherwise treat the whole line as an address. if (idx < 0) return { label: '', address: line.trim() }; const labelGuess = line.slice(0, idx).trim(); const rest = line.slice(idx + 1).trim(); const looksLikeLabel = labelGuess.length <= 40 && rest.includes(',') && !/\d/.test(labelGuess); if (looksLikeLabel) return { label: labelGuess, address: rest }; return { label: '', address: line.trim() }; } async function processCsvImport() { const ta = document.getElementById('csv-textarea'); const prog = document.getElementById('csv-progress'); const btn = document.getElementById('csv-import-btn'); const lines = ta.value.split(/\r?\n/).map(l => l.trim()).filter(Boolean); if (!lines.length) { prog.textContent = 'No addresses entered.'; return; } btn.disabled = true; let ok = 0, fail = 0; for (let i = 0; i < lines.length; i++) { const { label, address } = parseCsvLine(lines[i]); prog.textContent = `Geocoding ${i + 1} of ${lines.length}… ${ok} added, ${fail} failed`; try { const [hit] = await mapboxGeocode(address, { autocomplete: false, limit: 1 }); if (hit) { addProperty({ label, address: hit.place_name, lat: hit.lat, lng: hit.lng, focus: false }); ok++; } else { fail++; } } catch { fail++; } // Mapbox geocoder rate limit: 600 req/min. Keep under that with a small delay. await new Promise(r => setTimeout(r, 110)); } prog.textContent = `Done — ${ok} added, ${fail} failed.`; btn.disabled = false; if (ok && properties.length) { // Fit map to all property pins const bounds = new mapboxgl.LngLatBounds(); properties.forEach(p => bounds.extend([p.lng, p.lat])); map.fitBounds(bounds, { padding: 80, duration: 1200, maxZoom: 12 }); } setTimeout(closeCsvModal, 1400); } // ── Hook up after map + boot ─────────────────────────────────────────────── function initProperties() { ensurePropertySources(); loadProperties(); bindPropsSearch(); bindRingToggles(); refreshAllPinMarkers(); rebuildRings(); renderPropList(); } map.on('load', async () => { await boot(); initProperties(); });