Open to work: Senior Dev | GMT +2 | Technical Assessment Post | Spatial Disaster Skeleton

Hi people,

I’m on the lookout for a new challenge, so I setup a little technical assessment for myself to circumvent the process for those who are looking to hire a senior dev.

Please feel free to drop me an email at qinisogingqi99@gmail.com if you’d like to chat.

The core idea is straightforward. A user should be able to define the exact area they care about on a map, fetch natural disaster events relevant to that area, and then inspect a specific event in more detail, including the affected geometry where that data is available.

For the disaster data, I’m using GDACS. In simple terms, GDACS is a global disaster awareness and coordination system that provides structured disaster event data across different hazard types. It makes sense as a starting point for this kind of build because it already exposes event information in a format that can be queried and used in a live application.

For mapping, I’m using Mapbox. That choice was important because this project needed more than just dropping markers on a flat map. I needed proper interactive mapping, GeoJSON support, the ability to render event geometry, and the ability for a user to actually draw an area of interest. Mapbox gave me the flexibility to do that while still fitting into a Bubble build.

What made this especially interesting is that I’m not using Bubble in a completely standard way here. Bubble is still the frontend and workflow engine, but I’m leaning on HTML elements and custom JavaScript where I need more control over the mapping layer.

Here is a short loom of how this ‘skeleton’ app behaves: Updated quick demo.
If you’d like to interact with it yourself, but please do so moderately as GDACS provides a free service and therefore rate limits its service by domain Preview link.

On the main page, the map itself is rendered through a Bubble HTML element. That HTML element is responsible for loading Mapbox GL JS and Mapbox Draw assets, creating the map container, and providing the basic styling for the map area and draw status UI.

Main page HTML element:

<link href="https://api.mapbox.com/mapbox-gl-js/v3.19.1/mapbox-gl.css" rel="stylesheet">
<script src="https://api.mapbox.com/mapbox-gl-js/v3.19.1/mapbox-gl.js"></script>

<link
  rel="stylesheet"
  href="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-draw/v1.5.1/mapbox-gl-draw.css"
  type="text/css"
/>
<script src="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-draw/v1.5.1/mapbox-gl-draw.js"></script>

<style>
  #map {
    position: absolute;
    top: 0;
    bottom: 0;
    width: 100%;
  }

  #draw-status {
    position: absolute;
    bottom: 20px;
    left: 10px;
    z-index: 10;
    background: rgba(255, 255, 255, 0.95);
    color: #111;
    padding: 10px 12px;
    border-radius: 8px;
    font-family: Arial, sans-serif;
    font-size: 12px;
    line-height: 1.4;
    max-width: 280px;
    box-shadow: 0 2px 10px rgba(0, 0, 0, 0.15);
  }
</style>

<div id="map"></div>
<div id="draw-status">Draw a polygon on the map.</div>

The actual map initialization happens through a Run JavaScript action on page load. That script waits for Mapbox and Mapbox Draw to be available, initializes the map, adds the draw controls, and then listens for polygon create, update, and delete events. Once a polygon is drawn, it captures the geometry in a few different forms, including GeoJSON, raw coordinates, and WKT.

Main page Run JavaScript action:

function startCommandCenter() {
    if (typeof mapboxgl === "undefined" || typeof MapboxDraw === "undefined") {
        console.log("Mapbox or Mapbox Draw not ready yet... retrying...");
        setTimeout(startCommandCenter, 100);
        return;
    }

    mapboxgl.accessToken = "pk.*";

    if (window.myNativeMap) {
        try {
            window.myNativeMap.remove();
        } catch (e) {}
        window.myNativeMap = null;
    }

    const container = document.getElementById("map");
    if (!container) {
        console.log("Map container not found.");
        return;
    }

    window.myNativeMap = new mapboxgl.Map({
        container: "map",
        style: "mapbox://styles/qinisog/cmm2o1ox6006301qwh8to7wsc",
        center: [24, -28],
        zoom: 4,
        trackResize: true
    });

    window.myDrawControl = new MapboxDraw({
        displayControlsDefault: false,
        controls: {
            polygon: true,
            trash: true
        },
        defaultMode: "draw_polygon"
    });

    window.myNativeMap.addControl(new mapboxgl.NavigationControl());
    window.myNativeMap.addControl(window.myDrawControl, "top-right");

    function handlePolygonChange() {
        const data = window.myDrawControl.getAll();
        const status = document.getElementById("draw-status");

        if (!data.features.length) {
            window.drawnPolygonGeoJSON = null;
            window.drawnPolygonCoordinates = null;
            window.drawnPolygonCoordinatesString = "";
            window.drawnPolygonWKT = "";

            if (status) {
                status.innerHTML = "Draw a polygon on the map.";
            }

            if (typeof bubble_fn_polygongeojson === "function") {
                bubble_fn_polygongeojson("");
            }

            if (typeof bubble_fn_polygoncoordinates === "function") {
                bubble_fn_polygoncoordinates("");
            }

            if (typeof bubble_fn_polygonwkt === "function") {
                bubble_fn_polygonwkt("");
            }

            return;
        }

        const polygonFeature = data.features.find(function (feature) {
            return feature.geometry && feature.geometry.type === "Polygon";
        });

        if (!polygonFeature) {
            return;
        }

        const coordinates = polygonFeature.geometry.coordinates;
        const outerRing = coordinates[0] || [];

        const coordinatePairsString = outerRing
            .map(function (pair) {
                return pair[0] + " " + pair[1];
            })
            .join(",");

        const polygonWKT = "POLYGON((" + coordinatePairsString + "))";

        window.drawnPolygonGeoJSON = polygonFeature;
        window.drawnPolygonCoordinates = coordinates;
        window.drawnPolygonCoordinatesString = JSON.stringify(coordinates);
        window.drawnPolygonWKT = polygonWKT;

        console.log("Polygon GeoJSON:", polygonFeature);
        console.log("Polygon coordinates:", coordinates);
        console.log("Polygon coordinates string:", window.drawnPolygonCoordinatesString);
        console.log("Polygon WKT:", polygonWKT);

        if (status) {
            status.innerHTML = "Polygon captured successfully.";
        }

        if (typeof bubble_fn_polygongeojson === "function") {
            bubble_fn_polygongeojson(JSON.stringify(polygonFeature));
        }

        if (typeof bubble_fn_polygoncoordinates === "function") {
            bubble_fn_polygoncoordinates(JSON.stringify(coordinates));
        }

        if (typeof bubble_fn_polygonwkt === "function") {
            bubble_fn_polygonwkt(polygonWKT);
        }
    }

    window.myNativeMap.on("load", function () {
        window.myNativeMap.resize();
        console.log("Map successfully initialized via Toolbox.");
    });

    window.myNativeMap.on("draw.create", handlePolygonChange);
    window.myNativeMap.on("draw.update", handlePolygonChange);
    window.myNativeMap.on("draw.delete", handlePolygonChange);
}

startCommandCenter();

In practical terms, this is what that script is doing. It initializes the main map, enables polygon drawing, captures the selected area, converts it into formats I can work with, and pushes those values back into Bubble using Javascript to Bubble.

From there, I persist the drawn polygon to the current session user. More accurately, once the Javascript to Bubble element has a value, I save that polygon output into a text field on the current user called polygon_drawn. That lets me work with the selected area without forcing sign up just to test or use the app.

The fetch flow starts from the frontend button. When the user clicks Fetch alerts, I schedule the backend workflow fetch-gdcas immediately, and then I intentionally schedule it again about three hours later as a refresh mechanism.

Inside fetch-gdcas, I call GDACS Get Events with geometryarea = Current User's polygon_drawn. So at this point, I’m relying on GDACS to return the events whose points fall within the user-defined polygon.

Once GDACS returns the matching features, I use Bubble’s “Schedule API workflow on a list” action to pass each returned feature into another backend workflow called fetch-alerts.

Inside fetch-alerts, each incoming feature becomes a new Alert thing in Bubble. I map the event properties into fields like alert level, bbox, HTML description, event and episode scores, event dates, IDs, and event type. So the Alert table becomes the structured local representation of the results coming back from GDACS for that specific user-defined area. As this is simply a skeleton, duplication is not controlled, however if you wanted to control that you could user the event and episode id. The natural disasters event that are mapped each have a unique event id, with multiple episodes thus multiple episode ids per event for longer lasting natural disasters.

After creating the Alert, I then call Get Polygon - Get Polygon using the newly created Alert’s event_type, event_id, and episode_id. This is where I fetch more specific event geometry for the selected disaster.

If that polygon request returns a non-empty raw body, I save the response text into Alert.polygon_rawbody. I do this conditionally because not every event comes back with useful polygon data in the same way and secondly and more importantly, Bubble doesn’t seem to handle(stringify) geojson very well so we saved the entire raw body response and handle it with a script later. Side note Capture response headers must be checked to access this somewhat gated feature of saving a raw body response.

So the backend flow, in simple terms, is:

user draws polygon
i. polygon saved to current session user
ii. button triggers fetch-gdcas
iii. GDACS returns events within that geometry
iv. each returned feature becomes an Alert record
v. a second GDACS polygon call enriches that alert with raw geometry where available

On the frontend again, when a user selects a specific disaster event, I open a reusable whose data source is an Alert record from that table. The main page cards and listings are using the normal fields from the Alert table, but the reusable goes a step further and renders the event geometry from polygon_rawbody.

The reusable also uses an HTML element for the map container and base map setup.

Reusable HTML:

<html>
<head>
<meta charset="utf-8">
<title>Guides</title>
<meta name="viewport" content="initial-scale=1,maximum-scale=1,user-scalable=no">
<link href="https://api.mapbox.com/mapbox-gl-js/v3.19.1/mapbox-gl.css" rel="stylesheet">
<script src="https://api.mapbox.com/mapbox-gl-js/v3.19.1/mapbox-gl.js"></script>
<style>
body { margin: 0; padding: 0; }
#map { position: absolute; top: 0; bottom: 0; width: 100%; }
</style>
</head>
<body>
<div id="map"></div>
<script>
    mapboxgl.accessToken = 'pk.*';

    const map = new mapboxgl.Map({
        container: 'map',
        style: 'mapbox://styles/qinisog/cmm2o1ox6006301qwh8to7wsc',
        projection: 'globe',
        zoom: 1,
        center: [30, 15]
    });

    map.addControl(new mapboxgl.NavigationControl());
    map.scrollZoom.disable();

    map.on('style.load', () => {
        map.setFog({});

        const rawGeojson = `Alert-detail's Alert's polygon_rawbody`;

        if (!rawGeojson || rawGeojson.trim() === "") {
            console.log("No polygon_geojson found");
            return;
        }

        let geojson;

        try {
            geojson = JSON.parse(rawGeojson);
        } catch (e) {
            console.error("Invalid GeoJSON in polygon_geojson field", e);
            return;
        }

        if (map.getLayer('affected-fill')) map.removeLayer('affected-fill');
        if (map.getLayer('affected-outline')) map.removeLayer('affected-outline');
        if (map.getLayer('affected-point')) map.removeLayer('affected-point');
        if (map.getSource('affected-area')) map.removeSource('affected-area');

        map.addSource('affected-area', {
            type: 'geojson',
            data: geojson
        });

        map.addLayer({
            id: 'affected-fill',
            type: 'fill',
            source: 'affected-area',
            filter: ['==', '$type', 'Polygon'],
            paint: {
                'fill-color': '#ff7a00',
                'fill-opacity': 0.22
            }
        });

        map.addLayer({
            id: 'affected-outline',
            type: 'line',
            source: 'affected-area',
            filter: ['==', '$type', 'Polygon'],
            paint: {
                'line-color': '#ff4d00',
                'line-width': 3,
                'line-opacity': 0.95,
                'line-dasharray': [2, 1]
            },
            layout: {
                'line-join': 'round',
                'line-cap': 'round'
            }
        });

        map.addLayer({
            id: 'affected-point',
            type: 'circle',
            source: 'affected-area',
            filter: ['==', '$type', 'Point'],
            paint: {
                'circle-radius': 7,
                'circle-color': '#ff4d00',
                'circle-stroke-width': 2,
                'circle-stroke-color': '#ffffff'
            }
        });

        const bounds = new mapboxgl.LngLatBounds();

        const extendBounds = (coords) => {
            if (!Array.isArray(coords)) return;

            if (typeof coords[0] === 'number' && typeof coords[1] === 'number') {
                bounds.extend(coords);
                return;
            }

            coords.forEach(extendBounds);
        };

        if (geojson.type === 'FeatureCollection') {
            geojson.features.forEach(feature => {
                if (feature.geometry && feature.geometry.coordinates) {
                    extendBounds(feature.geometry.coordinates);
                }
            });
        } else if (geojson.type === 'Feature' && geojson.geometry) {
            extendBounds(geojson.geometry.coordinates);
        } else if (geojson.coordinates) {
            extendBounds(geojson.coordinates);
        }

        if (!bounds.isEmpty()) {
            map.fitBounds(bounds, {
                padding: 30,
                duration: 1200
            });
        }
    });
</script>
</body>
</html>

And when the reusable is opened, I run a separate JavaScript action that initializes the reusable’s map and renders only the affected-area polygon features from the saved raw polygon response.

Reusable Run JavaScript action:

function startCommandCenter() {
    if (typeof mapboxgl === 'undefined') {
        console.log("Mapbox library not found yet... retrying...");
        setTimeout(startCommandCenter, 100);
        return;
    }

    mapboxgl.accessToken = 'pk.eyJ1IjoicWluaXNvZyIsImEiOiJjbW02eXl4ajIwampnMnVxdWc1d2tyaXd6In0.ow3UAj_nzC0FdFSngFWxbA';
   
    window.myNativeMap = new mapboxgl.Map({
        container: 'map1',
        style: 'mapbox://styles/qinisog/cmm2o1ox6006301qwh8to7wsc',
        center: [24, -28],
        zoom: 4.0,
        trackResize: true
    });

    window.myNativeMap.on('load', () => {
        window.myNativeMap.resize();
        console.log("Map successfully initialized via Toolbox.");
        renderAffectedPolygon();
    });
}

function renderAffectedPolygon() {
    if (!window.myNativeMap) {
        console.log("Map not ready yet.");
        return;
    }

    const rawGeojsonText = `Alert-detail's Alert's polygon_rawbody`;

    if (!rawGeojsonText || !rawGeojsonText.trim()) {
        console.log("No polygon_rawbody found.");
        return;
    }

    let parsed;
    try {
        parsed = JSON.parse(rawGeojsonText);
    } catch (error) {
        console.log("Invalid JSON in polygon_rawbody", error);
        return;
    }

    const polygonFeatures = (parsed.features || []).filter(function(feature) {
        if (!feature || !feature.geometry) return false;

        const geometryType = feature.geometry.type;
        const polygonClass = feature.properties && feature.properties.Class;
        const polygonLabel = feature.properties && feature.properties.polygonlabel;

        return (
            (geometryType === "Polygon" || geometryType === "MultiPolygon") &&
            (
                polygonClass === "Poly_area" ||
                polygonClass === "Poly_Affected" ||
                polygonLabel === "Affected Area"
            )
        );
    });

    if (!polygonFeatures.length) {
        console.log("No affected-area polygon found in response.");
        return;
    }

    const cleanedGeojson = {
        type: "FeatureCollection",
        features: polygonFeatures
    };

    if (window.myNativeMap.getLayer('affected-fill')) {
        window.myNativeMap.removeLayer('affected-fill');
    }

    if (window.myNativeMap.getLayer('affected-outline')) {
        window.myNativeMap.removeLayer('affected-outline');
    }

    if (window.myNativeMap.getSource('affected-area')) {
        window.myNativeMap.removeSource('affected-area');
    }

    window.myNativeMap.addSource('affected-area', {
        type: 'geojson',
        data: cleanedGeojson
    });

    window.myNativeMap.addLayer({
        id: 'affected-fill',
        type: 'fill',
        source: 'affected-area',
        paint: {
            'fill-color': '#ff7a00',
            'fill-opacity': 0.22
        }
    });

    window.myNativeMap.addLayer({
        id: 'affected-outline',
        type: 'line',
        source: 'affected-area',
        paint: {
            'line-color': '#ff4d00',
            'line-width': 2.5,
            'line-opacity': 0.95
        },
        layout: {
            'line-join': 'round',
            'line-cap': 'round'
        }
    });

    const bounds = new mapboxgl.LngLatBounds();

    function extendBounds(coords) {
        if (!Array.isArray(coords)) return;

        if (typeof coords[0] === 'number' && typeof coords[1] === 'number') {
            bounds.extend(coords);
            return;
        }

        coords.forEach(extendBounds);
    }

    cleanedGeojson.features.forEach(function(feature) {
        extendBounds(feature.geometry.coordinates);
    });

    if (!bounds.isEmpty()) {
        window.myNativeMap.fitBounds(bounds, {
            padding: 30,
            duration: 1200
        });
    }

    console.log("Affected polygon rendered successfully.");
}

startCommandCenter();

That reusable script does a few things. It initializes the event-detail map, reads the saved polygon_rawbody from the selected Alert, parses the GeoJSON, filters out only the actual affected-area polygon features, adds them as a source and layers on the map, and then fits the map bounds to that geometry.

So at this stage, the application already has the core loop working:

a user defines an area
i. the app fetches disasters within that area
ii. those events are saved into an Alert table
iii. the user selects an event
iv. the app renders the detailed affected area geometry for that event where available

There are still rough edges. Duplicate alert control is not handled, pagination not handled either, mainly because this is more backend & custom solution focused than anything else. But the core system is already in place, and for me that was the important milestone: proving that Bubble, Mapbox, custom JavaScript, and GDACS can be combined into a working spatial disaster monitoring workflow.

Updated the demo and added a preview link so you can interact with the app yourself.