diff --git a/components/.DS_Store b/components/.DS_Store index c953edd..9419dba 100644 Binary files a/components/.DS_Store and b/components/.DS_Store differ diff --git a/components/geo-map-component/README.md b/components/geo-map-component/README.md new file mode 100644 index 0000000..f885d0e --- /dev/null +++ b/components/geo-map-component/README.md @@ -0,0 +1,47 @@ +# 🌍 GeoMap Component (Retool Custom Component) + +A high-performance, interactive world map component for Retool that visualizes country-level data with dynamic color scaling, tooltips, and click interactions. + +--- + +## 🚀 Features + +- 🌍 World map visualization (Leaflet-based) +- 🎨 Dynamic color gradient (0–100 scale) +- 🧠 Auto country detection (supports names, ISO2, ISO3) +- ⚡ High-performance rendering (optimized updates, caching) +- 🖱 Click interaction with output state +- 🔔 Event handler support (`onSelect`) +- 📊 Smart normalization (supports % and raw values) +- 📌 Sticky legend (color scale indicator) +- 💅 Modern tooltip UI +- 🛡 Safe fallback colors (prevents UI break) + +--- + +## 📦 Inputs + +| Name | Type | Description | +|----------------|--------|------------| +| `data` | Array | Input dataset | +| `countryKey` | String | Field name for country | +| `valueKey` | String | Field name for value | +| `lowColor` | String | Color for lowest values (0%) | +| `midLowColor` | String | Color for 25% | +| `midHighColor` | String | Color for 75% | +| `highColor` | String | Color for highest values (100%) | + +--- + +## 📤 Outputs + +| Name | Type | Description | +|-------------------|--------|------------| +| `selectedCountry` | Object | Selected country data | + +Example: +```json +{ + "country": "India", + "value": 1400 +} \ No newline at end of file diff --git a/components/geo-map-component/cover.png b/components/geo-map-component/cover.png new file mode 100644 index 0000000..8d613a1 Binary files /dev/null and b/components/geo-map-component/cover.png differ diff --git a/components/geo-map-component/metadata.json b/components/geo-map-component/metadata.json new file mode 100644 index 0000000..fe588ab --- /dev/null +++ b/components/geo-map-component/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "geo-map-component", + "title": "Geo Map", + "author": "@widlestudiollp", + "shortDescription": "An interactive world map component that visualizes country-level data using dynamic color scaling, smart country detection, and click-based interactions.", + "tags": ["Maps", "Geospatial", "Data Visualization", "Leaflet", "Analytics"] +} \ No newline at end of file diff --git a/components/geo-map-component/package.json b/components/geo-map-component/package.json new file mode 100644 index 0000000..4d4a9af --- /dev/null +++ b/components/geo-map-component/package.json @@ -0,0 +1,46 @@ +{ + "name": "my-react-app", + "version": "0.1.0", + "private": true, + "dependencies": { + "@tryretool/custom-component-support": "latest", + "leaflet": "^1.9.4", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "scripts": { + "dev": "npx retool-ccl dev", + "deploy": "npx retool-ccl deploy" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "devDependencies": { + "@types/react": "^18.2.55", + "@typescript-eslint/eslint-plugin": "^7.3.1", + "@typescript-eslint/parser": "^7.3.1", + "eslint": "^8.57.0", + "eslint-plugin-react": "^7.34.1", + "postcss-modules": "^6.0.0", + "prettier": "^3.0.3" + }, + "retoolCustomComponentLibraryConfig": { + "name": "MapComponent", + "label": "Map Component", + "description": "Map component with tool-tip and color bg with percentage of value.", + "entryPoint": "src/index.tsx", + "outputPath": "dist" + } +} \ No newline at end of file diff --git a/components/geo-map-component/src/index.tsx b/components/geo-map-component/src/index.tsx new file mode 100644 index 0000000..e8c2e82 --- /dev/null +++ b/components/geo-map-component/src/index.tsx @@ -0,0 +1,227 @@ +import React, { type FC, useEffect, useRef } from 'react' +import { Retool } from '@tryretool/custom-component-support' +import L from 'leaflet' +import 'leaflet/dist/leaflet.css' +import './style.css' + +let geoJsonCache: any = null + +export const GeoMapComponent: FC = () => { + Retool.useComponentSettings({ + defaultWidth: 12, + defaultHeight: 60 + }) + + const [data] = Retool.useStateArray({ name: 'data', initialValue: [] }) + const [countryKey] = Retool.useStateString({ name: 'countryKey', initialValue: 'country' }) + const [valueKey] = Retool.useStateString({ name: 'valueKey', initialValue: 'value' }) + + const [lowColor] = Retool.useStateString({ name: 'lowColor', label: 'Low Value Color (0%)', initialValue: '#ffaa6e' }) + const [midLowColor] = Retool.useStateString({ name: 'midLowColor', label: 'Mid-Low Color (25%)', initialValue: '#f7c873' }) + const [midHighColor] = Retool.useStateString({ name: 'midHighColor', label: 'Mid-High Color (75%)', initialValue: '#7297ef' }) + const [highColor] = Retool.useStateString({ name: 'highColor', label: 'High Value Color (100%)', initialValue: '#1d2e6b' }) + + const [selectedCountry, setSelectedCountry] = Retool.useStateObject({ + name: 'selectedCountry', + initialValue: {} + }) + + const onSelect = Retool.useEventCallback({ name: 'onSelect' }) + + const mapRef = useRef(null) + const geoLayerRef = useRef(null) + const layerMapRef = useRef>(new Map()) + const currentDataRef = useRef>(new Map()) + const selectedLayerRef = useRef(null) + const containerRef = useRef(null) + + const safeLowColor = lowColor || '#ffaa6e' + const safeMidLowColor = midLowColor || '#f7c873' + const safeMidHighColor = midHighColor || '#7297ef' + const safeHighColor = highColor || '#1d2e6b' + + const countryCodeMap: Record = { + us: 'united states of america', + usa: 'united states of america', + in: 'india', + ind: 'india', + br: 'brazil', + bra: 'brazil', + uk: 'united kingdom', + gb: 'united kingdom' + } + + const normalize = (v: string) => + v.toLowerCase().replace(/[.,]/g, '').replace(/\s+/g, ' ').trim() + + const resolveCountry = (input: string) => + countryCodeMap[normalize(input)] || normalize(input) + + const hexToRgb = (hex: string) => [ + parseInt(hex.slice(1, 3), 16), + parseInt(hex.slice(3, 5), 16), + parseInt(hex.slice(5, 7), 16) + ] + + const interpolate = (a: string, b: string, t: number) => { + const [r1, g1, b1] = hexToRgb(a) + const [r2, g2, b2] = hexToRgb(b) + return `rgb(${r1 + (r2 - r1) * t},${g1 + (g2 - g1) * t},${b1 + (b2 - b1) * t})` + } + + const getColor = (v: number) => { + v = Math.max(0, Math.min(100, v)) + if (v <= 25) return interpolate(safeLowColor, safeMidLowColor, v / 25) + if (v <= 75) return interpolate(safeMidLowColor, safeMidHighColor, (v - 25) / 50) + return interpolate(safeMidHighColor, safeHighColor, (v - 75) / 25) + } + + useEffect(() => { + if (!containerRef.current || mapRef.current) return + + const map = L.map(containerRef.current, { + minZoom: 1.5, + maxZoom: 5, + attributionControl: false, + worldCopyJump: true, + maxBoundsViscosity: 0, + scrollWheelZoom: true, + inertia: true + }).setView([20, 0], 2) + + mapRef.current = map + + const loadGeo = async () => { + if (!geoJsonCache) { + const res = await fetch('https://raw.githubusercontent.com/johan/world.geo.json/master/countries.geo.json') + geoJsonCache = await res.json() + } + + const layer = L.geoJSON(geoJsonCache, { + style: { fillColor: '#fff', weight: 0.5, color: '#ccc', fillOpacity: 1 }, + onEachFeature: (feature, layer) => { + const name = resolveCountry(feature.properties.name) + layerMapRef.current.set(name, layer) + + layer.on('click', () => { + const clickedName = feature.properties.name + const normalized = resolveCountry(clickedName) + const val = currentDataRef.current.get(normalized) + + if (selectedLayerRef.current) { + selectedLayerRef.current.setStyle({ weight: 0.5, color: '#666' }) + } + + layer.setStyle({ weight: 2, color: '#000' }) + selectedLayerRef.current = layer + + const payload = { country: clickedName, value: val ?? null } + + setSelectedCountry(payload) + onSelect(payload) + }) + } + }).addTo(map) + + geoLayerRef.current = layer + } + + loadGeo() + }, []) + + useEffect(() => { + if (!geoLayerRef.current) return + + const parsed = new Map() + let min = Infinity, max = -Infinity + + for (const row of data) { + const c = resolveCountry(row[countryKey]) + const v = parseFloat(String(row[valueKey]).replace('%', '')) + if (!isNaN(v)) { + parsed.set(c, v) + min = Math.min(min, v) + max = Math.max(max, v) + } + } + + currentDataRef.current = parsed + + const normalizeVal = (v: number) => + max <= 100 ? v : min === max ? 50 : ((v - min) / (max - min)) * 100 + + layerMapRef.current.forEach((layer, name) => { + const val = parsed.get(name) + + if (val !== undefined) { + const norm = normalizeVal(val) + const color = getColor(norm) + + layer.setStyle({ + fillColor: color, + color: '#666', + fillOpacity: 0.9, + weight: selectedLayerRef.current === layer ? 2 : 0.5 + }) + + const label = valueKey.replace(/_/g, ' ').replace(/\b\w/g, (c: string) => c.toUpperCase()) + + const displayValue = max <= 100 ? `${val.toFixed(2)}%` : val.toLocaleString() + + layer.bindTooltip( + `
+
${layer.feature.properties.name}
+
${displayValue}
+
+ + ${label} +
+
`, + { sticky: true, direction: 'top', opacity: 1, className: 'custom-modern-tooltip' } + ) + } else { + layer.setStyle({ + fillColor: '#fff', + color: '#ccc', + fillOpacity: 1, + weight: 0.5 + }) + layer.unbindTooltip() + } + }) + }, [data, countryKey, valueKey, lowColor, midLowColor, midHighColor, highColor]) + + return ( +
+
+ +
+
100
+ +
+ +
0
+
+
+ ) +} \ No newline at end of file diff --git a/components/geo-map-component/src/style.css b/components/geo-map-component/src/style.css new file mode 100644 index 0000000..5403b4f --- /dev/null +++ b/components/geo-map-component/src/style.css @@ -0,0 +1,76 @@ +/* =========================== + MAP CONTAINER +=========================== */ +.map-wrapper { + position: relative; + height: 100%; + width: 100%; +} + +#map { + height: 100%; + width: 100%; + z-index: 1; + background-color: #CFD3D4; +} + +/* =========================== + COLOR BAR (RIGHT SIDE) +=========================== */ +.colorbar-container { + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + background: #ffffff; + padding: 10px 8px; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + z-index: 999; + font-family: system-ui, -apple-system, sans-serif; +} + +.colorbar { + width: 12px; + height: 150px; + border-radius: 6px; + background: linear-gradient(to bottom, #1d2e6b, #7297ef, #f7c873, #ffaa6e); +} + +.colorbar-labels { + display: flex; + flex-direction: column; + justify-content: space-between; + height: 150px; + font-size: 10px; + color: #555; +} + +/* =========================== + TOOLTIP CLEAN (MODERN) +=========================== */ +.leaflet-tooltip { + background: transparent; + border: none; + box-shadow: none; + padding: 0; +} + +.leaflet-tooltip.custom-modern-tooltip { + background: transparent; + border: none; + box-shadow: none; + padding: 0; +} + +/* =========================== + MAP INTERACTION CLEANUP +=========================== */ +.leaflet-interactive:focus { + outline: none !important; + box-shadow: none !important; +} \ No newline at end of file