import React, { useRef, useCallback, useState, useEffect, useMemo } from 'react'
import Map, { Source, Layer, NavigationControl, Popup } from 'react-map-gl/maplibre'
import 'maplibre-gl/dist/maplibre-gl.css'
const MAP_STYLE = "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json"
const ENTITY_COLORS = {
startup: '#3B82F6', sme: '#10B981', college_ecell: '#FBBF24',
incubator: '#A855F7', accelerator: '#EC4899', coworking: '#14B8A6', investor: '#EF4444',
}
const ENTITY_LABELS = {
startup: 'π Startup', sme: 'π’ SME', college_ecell: 'π E-Cell',
incubator: 'π§ͺ Incubator', accelerator: 'β‘ Accelerator',
}
// βββ Safe social icons (React elements, not raw HTML) ββββββββββββββββββββββββββ
const SafeSocialIcon = ({ icon }) => {
const icons = {
linkedin: πΌ,
twitter: π,
instagram: πΈ,
facebook: π,
website: π,
google: π,
}
return icons[icon] || π
}
// βββ URL validation helper βββββββββββββββββββββββββββββββββββββββββββββββββββ
function isValidHttpUrl(url) {
if (!url || typeof url !== 'string') return false
try {
const u = new URL(url)
return u.protocol === 'http:' || u.protocol === 'https:'
} catch {
return false
}
}
function sanitizeUrl(url) {
if (!url) return ''
// Remove javascript: and data: prefixes
const lower = url.trim().toLowerCase()
if (lower.startsWith('javascript:') || lower.startsWith('data:') || lower.startsWith('vbscript:')) {
return ''
}
return url
}
function SocialLinks({ entity }) {
const links = []
const name = entity.name
if (entity.linkedin_url && isValidHttpUrl(entity.linkedin_url)) links.push({ name: 'LinkedIn', url: sanitizeUrl(entity.linkedin_url), icon: 'linkedin', color: '#0A66C2' })
if (entity.twitter_url && isValidHttpUrl(entity.twitter_url)) links.push({ name: 'X (Twitter)', url: sanitizeUrl(entity.twitter_url), icon: 'twitter', color: '#000000' })
if (entity.instagram_url && isValidHttpUrl(entity.instagram_url)) links.push({ name: 'Instagram', url: sanitizeUrl(entity.instagram_url), icon: 'instagram', color: '#E4405F' })
if (entity.facebook_url && isValidHttpUrl(entity.facebook_url)) links.push({ name: 'Facebook', url: sanitizeUrl(entity.facebook_url), icon: 'facebook', color: '#1877F2' })
if (entity.website && isValidHttpUrl(entity.website)) links.push({ name: 'Website', url: sanitizeUrl(entity.website), icon: 'website', color: '#6366F1' })
// Always add search links even if no stored URL
const encName = encodeURIComponent(name)
const slug = name.toLowerCase().replace(/\s+/g, '-').replace('.', '')
if (!entity.linkedin_url) links.push({ name: 'LinkedIn', url: `https://www.linkedin.com/search/results/companies/?keywords=${encName}`, icon: 'linkedin', color: '#0A66C2', search: true })
if (!entity.twitter_url) links.push({ name: 'X', url: `https://x.com/search?q=${encName}&src=typed_query`, icon: 'twitter', color: '#000000', search: true })
if (!entity.instagram_url) links.push({ name: 'Instagram', url: `https://www.instagram.com/${slug}`, icon: 'instagram', color: '#E4405F', search: true })
if (!entity.website) links.push({ name: 'Google', url: `https://www.google.com/search?q=${encName}+company`, icon: 'google', color: '#4285F4', search: true })
links.push({ name: 'Crunchbase', url: `https://www.crunchbase.com/organization/${slug}`, icon: 'crunchbase', color: '#0288D1', search: true })
links.push({ name: 'Tracxn', url: `https://tracxn.com/d/companies/${slug}/`, icon: 'tracxn', color: '#FF6F00', search: true })
return (
)
}
function PopupContent({ properties, onDetailClick }) {
const p = properties
const color = ENTITY_COLORS[p.entity_type] || '#64748B'
const label = ENTITY_LABELS[p.entity_type] || p.entity_type
let sectors = p.sectors
if (typeof sectors === 'string') { try { sectors = JSON.parse(sectors) } catch { sectors = [] } }
return (
{p.name}
π {p.city}, {p.state}
{label}
{p.description &&
{p.description}
}
{p.dpiit_recognized && β
Verified}
{p.unicorn_status === 'unicorn' && π¦ Unicorn}
{p.is_women_led && π© Women-led}
{p.nsa_winner && π NSA}
{(Array.isArray(sectors) ? sectors : []).slice(0, 4).map(s => (
{s}
))}
{p.founded_year && π
{p.founded_year}}
{p.linkedin_team_size && π₯ {p.linkedin_team_size}}
{p.funding_display && p.funding_display !== 'Bootstrapped' && π° {p.funding_display}}
{/* Social Media Links */}
{/* AI Analysis button */}
)
}
export default function StartupMap({ geojson, onViewportChange, onEntityClick, loading, mapMode, zoom: currentZoom, flyTo, onFlyToConsumed }) {
const mapRef = useRef(null)
const [popup, setPopup] = useState(null)
useEffect(() => {
if (flyTo && mapRef.current) {
mapRef.current.flyTo({
center: [flyTo.lng, flyTo.lat],
zoom: flyTo.zoom || 8,
duration: 1800,
essential: true,
curve: 1.42,
})
onFlyToConsumed?.()
}
}, [flyTo, onFlyToConsumed])
const onMoveEnd = useCallback((evt) => {
const map = evt.target
const bounds = map.getBounds()
const z = map.getZoom()
onViewportChange({
min_lng: bounds.getWest(),
max_lng: bounds.getEast(),
min_lat: bounds.getSouth(),
max_lat: bounds.getNorth(),
}, z)
}, [onViewportChange])
const isClusterMode = useMemo(() => geojson?.cluster_mode === true, [geojson])
const onMapClick = useCallback((evt) => {
const pf = evt.target.queryRenderedFeatures(evt.point, { layers: ['entity-points', 'entity-icons'] })
if (pf.length > 0) {
const feature = pf[0]
const props = feature.properties
let sectors = props.sectors
if (typeof sectors === 'string') { try { sectors = JSON.parse(sectors) } catch { sectors = [] } }
setPopup({
longitude: feature.geometry.coordinates[0],
latitude: feature.geometry.coordinates[1],
properties: { ...props, sectors },
isCluster: false,
})
return
}
const cf = evt.target.queryRenderedFeatures(evt.point, { layers: ['server-clusters'] })
if (cf.length > 0) {
const cluster = cf[0]
const targetZoom = Math.min((cluster.properties.expansion_zoom || currentZoom + 2), 18)
evt.target.flyTo({ center: cluster.geometry.coordinates, zoom: targetZoom, duration: 800 })
return
}
const ccf = evt.target.queryRenderedFeatures(evt.point, { layers: ['client-clusters'] })
if (ccf.length > 0) {
const src = evt.target.getSource('entities')
if (src?.getClusterExpansionZoom) {
src.getClusterExpansionZoom(ccf[0].properties.cluster_id, (err, z) => {
if (!err) evt.target.flyTo({ center: ccf[0].geometry.coordinates, zoom: z + 1, duration: 800 })
})
}
}
}, [currentZoom])
const onMouseEnter = useCallback(() => {
if (mapRef.current) mapRef.current.getCanvas().style.cursor = 'pointer'
}, [])
const onMouseLeave = useCallback(() => {
if (mapRef.current) mapRef.current.getCanvas().style.cursor = ''
}, [])
const showHeatmap = mapMode === 'heatmap'
const showClusters = mapMode === 'clusters'
const showPoints = mapMode === 'points'
const { clusterFeatures, pointFeatures } = useMemo(() => {
if (!geojson?.features) return { clusterFeatures: null, pointFeatures: null }
if (isClusterMode) return { clusterFeatures: { type: 'FeatureCollection', features: geojson.features }, pointFeatures: null }
return { clusterFeatures: null, pointFeatures: geojson }
}, [geojson, isClusterMode])
const interactiveLayers = useMemo(() => {
const l = ['entity-points', 'entity-icons']
if (isClusterMode) l.push('server-clusters')
if (showPoints) l.push('client-clusters')
return l
}, [isClusterMode, showPoints])
return (
)
}