Back to home
Case study

SpearMap

A data-driven spearfishing tool that brings together weather, tides, geography and species behaviour to answer a simple question: where and when are the best dive opportunities?

Visit live project
SpearMap
Background

I started spearfishing in 2023, and my performance so far has been fairly underwhelming.

If you’ve ever watched a Daniel Mann video, you’d be forgiven for thinking that as soon as you drop in, you’ll be surrounded by schools of bass, pollock peering out from gullies, and the occasional lobster conveniently within reach. In reality, it’s nothing like that.

On my first few dives, the visibility was poor and it was barely worth being in the water. On the days when the visibility was clear, I often didn’t see any fish at all. On top of that, there’s the practical constraint of needing a dive buddy. Trying to line up conditions, fish activity, and someone else’s availability quickly becomes difficult.

It was only through being part of the Exeter Spearfishing Club that things started to make more sense. Visibility wasn’t random. It depended on swell, wind direction, and recent weather. Fish behaviour wasn’t random either. Different species appear under different conditions, move with the seasons, and favour particular depths and types of ground.

On top of that, species don’t just respond to conditions, they respond to habitat. Some prefer reef, others sand. Some push into deeper water in colder months and return as temperatures rise. The number of variables adds up quickly and changes over time.

I have tools like Windy, tide tables, and Navionics, but they all live in isolation. One app for wind, one for tides, one for charts. Trying to combine them into a single decision in my head is slow, and often wrong.

I wanted to bring those signals together into one place. To visualise the seabed, layer in the conditions, and see how things change over the course of a week.

Ultimately, I just wanted a better answer to the question I kept asking myself: where are the best spots, and when is it actually worth going?

Timeline

Meaningful milestones

Curated entries from planning through launch and iteration.

17 January 2026

Working Out What Actually Matters

planning

Before building anything, I spent some time trying to break the problem down properly.

From experience, I already had a rough sense of the variables: visibility, swell, wind, tides, habitat and species behaviour. But that wasn't enough to build something reliable. I needed to understand which of these actually mattered, how they interacted and where the useful thresholds were.

I focused on a small set of species that are realistically targeted when diving along the South Devon coast. European bass, pollock and a couple of flatfish species, along with lobster for night diving. The aim wasn't to model everything, just to get a representative spread of behaviours.

From there I looked at each variable in turn: how temperature affects feeding and seasonal movement, how tidal flow and phase influence activity, how visibility and turbidity affect hunting success, how habitat type changes what you're likely to see, and whether things like pressure or moon phase actually make a difference.

At the same time, I tried to separate what affects fishing quality from what affects safety. They're related, but not the same problem.

Why this mattered

Up to this point, most of my decisions in the water had been based on instinct or guesswork.

If the goal was to build something useful, I needed to move beyond that. Not necessarily to perfect accuracy, but to something grounded in reality and consistent enough to trust.

It also became clear quite quickly that not all variables are equal. Some act as hard constraints, like visibility. Others are more like modifiers.

Key decisions

Focus on a small number of species rather than trying to generalise across everything. Treat habitat and conditions as separate layers. Separate fishing quality from safety entirely. Think in terms of thresholds and interactions, not just individual variables.

Trade-off

There's a limit to how far you can take this with published data alone. Some areas are well understood, others are thin or inconsistent. At a certain point, you have to combine what evidence exists with practical experience and accept that the model will be an approximation rather than a perfect representation.

16 April 2026

Getting Something on the Map

design

Once I had a rough idea of the variables involved, the next step was to get something working end-to-end.

I set up a monorepo with three parts: a frontend, an API and a shared package for types. The frontend was a simple React app, the API handled data and scoring, and the shared package kept the data structures consistent between the two.

From there I wired up Mapbox and rendered an initial grid over the coastline. At this point the data was mostly placeholder, but that wasn't the focus. The goal was to get a basic loop working where the frontend could request data and display it spatially.

I also added a simple time slider and a basic legend so I could start thinking in terms of how the data would actually be explored, not just calculated.

Why this mattered

Up to this point everything had been abstract. Variables, thresholds, ideas about how things might fit together.

Getting something on a map changes that quickly. You start to see scale, density and how awkward certain assumptions are when they're visualised. It also gives you something to react to. Even if it's wrong, it's much easier to see what's wrong.

Key decisions

Split the system into a frontend and API early, rather than building everything in one place. Use a shared TypeScript package so the same types flow from backend to frontend. Generate the spatial grid server-side and return already-processed cells to the frontend.

Trade-off

This is more structure than you need just to get something on the map. It would have been quicker to build everything in a single app and refactor later, but you'd be acquiring technical debt with every feature. The separation makes it easier to evolve the system over time and keeps the frontend focused on visualisation rather than data logic.

What I noticed

Even with placeholder data, a few things became obvious straight away. The grid was too large and included a lot of irrelevant space. The map felt noisy. And without any filtering, it was hard to tell what was actually useful. That set up the next step quite naturally: reduce the problem down to something that actually reflects where you'd go in the water.

16 April 2026

Removing the Noise

build

With the first version of the map in place, I was rendering a full grid across the bounding box I'd defined for the coastline. Technically it worked, but most of what I was looking at wasn't useful. Large parts of the grid covered open water well offshore, and others fell on land. Neither are relevant when you're deciding where to dive.

The problem

The signal was there, but it was buried in noise. Instead of helping narrow things down, the map made everything feel equally important. It was hard to tell where to look, and even harder to build any kind of intuition from it.

What I changed

I introduced a simple coastal filter. Cells were limited to roughly one nautical mile from the shoreline, and anything intersecting land was excluded. This aligned the map more closely with how I actually dive, which is mostly inshore and within a short distance of access points.

Why this mattered

This was the first point where the tool started to reflect real-world use rather than just technical capability. By reducing the dataset to places that are realistically diveable, the map became something I could actually read and reason about.

Trade-off

It's a simplification. There will be valid spots outside that range, especially when using a boat, and some edge cases near the coastline still aren't perfect. But for a first pass, it was more important to be directionally correct than completely exhaustive.

What I noticed

Instead of scanning across a large, mostly irrelevant grid, I was looking at a much smaller set of locations that actually made sense. Patterns became easier to spot and the whole thing felt more focused. It also highlighted the next problem: even with the right areas selected, the underlying data still needed to be more realistic.

17 April 2026

Choosing a Data Strategy

build

With the map focused on the right areas, the next step was replacing placeholder values with real marine data.

I integrated Stormglass and started pulling in conditions like swell, wind, temperature and tides. Rather than querying per cell, I introduced a small set of representative stations and shared that data across nearby locations. A station cache loads at startup, persists to disk and refreshes once a day in the background, keeping API costs proportional to relevant geography regardless of traffic.

The problem

As soon as real data came in, a couple of issues became obvious.

Calling an external API for every cell and time step doesn't scale. Even with a relatively small grid, the number of requests adds up quickly. More importantly, the data itself is uneven. Marine conditions don't change cleanly at the scale of individual cells. Treating every cell as completely independent gives a false sense of precision.

What I changed

I moved to a station-based approach. A handful of points provide the underlying conditions and each grid cell inherits from its nearest station. This keeps the data consistent across an area while still allowing the spatial layer to do its job.

I also started thinking more carefully about which variables actually needed to be fetched and which ones would eventually be derived or combined.

Why this mattered

This was the point where the system started behaving more like a model and less like a collection of values. Instead of pushing raw data straight to the frontend, the API became responsible for shaping it into something usable. The frontend just renders the result.

It also made it much clearer how the different variables interact. With placeholder data, everything looks clean and predictable. With real data, you start to see contradictions and edge cases.

Trade-off

You lose some local detail by not modelling every cell independently. But in practice, the consistency is more useful than the illusion of precision. It also keeps the system predictable in terms of performance and cost.

What I noticed

Once real data was flowing, the limitations of the scoring approach became obvious. Some variables clearly mattered more than others, and certain combinations produced results that didn't make sense. Good-looking scores appeared in conditions I wouldn't actually dive in, and genuinely promising windows were sometimes understated. That set up the next step: rethinking how the model interprets the data, rather than just collecting it.

17 April 2026

Rethinking the Scoring Model

design

What wasn't working

Once real data started coming through, the scoring model became the weak point quite quickly.

The initial approach was additive. Each variable contributed a fixed number of points and the final score was just the sum of those parts. On paper it looked reasonable, but in practice it produced results that didn't match how things actually work in the water.

You'd get situations where strong tidal movement and good temperature would push the score up, even when visibility was poor. Technically correct according to the formula, but not something you'd ever act on.

What I changed

I moved away from a purely additive model and started treating the system as a set of interacting constraints. Instead of everything contributing independently, certain signals act as gates. Visibility in particular became a limiting factor rather than just another input. If it's poor, the overall score falls away regardless of what the rest of the conditions are doing.

This led to a more multiplicative structure, where weaker signals pull the overall result down rather than being balanced out.

Why this mattered

This was the point where the model started to feel closer to real decision-making.

When you're actually planning a dive, you don't mentally add up scores. You rule things out first. If the visibility isn't there, the rest doesn't matter. If the conditions are right but the habitat is poor, you lower your expectations. The scoring needed to reflect that kind of thinking, not just combine variables mechanically.

Key decisions

Move from additive scoring to a model that allows variables to constrain each other. Treat visibility as a primary limiting factor rather than a weighted input. Keep habitat and species behaviour separate from raw conditions so they can evolve independently. Focus on producing a score that feels right in practice, not just one that is mathematically neat.

Trade-off

A multiplicative or constraint-based model is harder to reason about and tune. With an additive system, you can adjust weights and see predictable changes. With interacting variables, small changes can have larger and sometimes unexpected effects. It also relies more on judgement where the underlying data is uncertain.

What I noticed

The outputs became less balanced, but more realistic. Good days became slightly rarer, poor days more clearly defined, and the middle range started to represent genuinely marginal conditions rather than everything averaging out. More importantly, the results started to align better with the kind of decisions I'd actually make.

17 April 2026

Separating "Good" from "Safe"

design

What wasn't sitting right

As the scoring model started to improve, one thing still felt off.

Some of the highest scoring conditions were not necessarily ones I'd actually go out in. Strong swell, heavy wind or fast-moving water can still produce good feeding conditions, but they also make diving difficult or unsafe. Blending everything into a single score started to feel misleading.

What I changed

I separated fishing quality from safety completely.

The main score is now focused purely on how good the conditions are for finding fish. Things like swell, wind and current still influence it, but only in terms of visibility and behaviour, not risk. Safety is handled as a separate layer with its own thresholds and flags, assessed against UK spearfishing guidance across five axes: wave height, wave energy, wind speed, current speed and water temperature.

Why this mattered

In practice, these are two different decisions. One is "would this be a good day for fish?" The other is "should I actually get in the water?" Trying to answer both with a single number hides that distinction and leads to confusing results.

By separating them, the trade-off becomes clearer. A day can look promising from a fishing point of view but still require caution, or vice versa. A cell can score well and still carry a danger flag. Conflating the two would either penalise genuinely productive conditions or understate the risk.

Trade-off

It adds a bit more complexity to the interface and how results are interpreted. Users now need to consider two signals instead of one. But that reflects the reality of the decision rather than simplifying it away.

What I noticed

The output became easier to trust. Instead of wondering whether a low score was due to poor fishing conditions or just rough weather, the reasoning is more transparent. It also allows for more honest outputs, where a location can look genuinely good while still being flagged as challenging.

18 April 2026

Finding the Best Opportunities

build

With the scoring model in place, the next step was to make the output more actionable.

I added a "Best This Week" view that scans all locations across a rolling window and surfaces the strongest opportunities. Instead of looking at a single point in time, it considers how conditions evolve over several days and highlights the peaks. Alongside the best individual hour, it also computes the best contiguous window of high-quality conditions, so a spot with four solid consecutive hours ranks ahead of one with a single isolated peak.

The problem

The first version worked, but the results weren't particularly helpful. It tended to return multiple top results clustered in the same area, often just neighbouring cells with very similar scores. Technically correct, but not very useful if you're trying to decide where to go.

What I changed

I introduced a filtering step to spread results out geographically. Rather than taking the absolute top scores, the system selects high-performing locations while enforcing a minimum distance between them. This keeps the output varied and more representative of actual options.

Why this mattered

This was the first time the system felt like it was helping make a decision rather than just presenting data. Instead of scanning the map and trying to interpret everything yourself, you get a short list of options that are already filtered and prioritised.

Trade-off

You're no longer seeing the pure top scores. Some very similar or slightly better locations might be excluded in favour of variety. But in practice, that's usually more useful than seeing five versions of the same spot.

What I noticed

It also highlighted something else: timing matters just as much as location. A spot might look average most of the week but have a short window where everything lines up. That naturally led to the next step.

18 April 2026

Bringing Time into the Picture

build

Up to this point, the system was identifying strong conditions but not properly accounting for when those conditions occurred.

I added a time-aware layer to the model and interface, including day and night detection based on solar position. This feeds into both the map and the timeline, so each window of conditions is grounded in a specific time of day. Behind the slider, a sinusoidal curve represents the sun's altitude across all 168 hours, giving an immediate visual sense of which hours are daylight without reading timestamps.

The problem

Some of the best scoring windows were happening at times that weren't practical. Night-time peaks would show up as strong opportunities, but in the UK you can't spearfish before sunrise or after sunset. The data was technically correct, but not especially helpful.

What I changed

I incorporated daylight awareness into the system. Each time window is now tagged based on whether it falls within usable hours. Rather than removing night-time data completely, it's still visible but clearly contextualised. The slider fill is also shaded by median score across all cells per hour, turning the scrubber into a compact 168-hour forecast summary you can read at a glance.

Why this mattered

This brought the model closer to how decisions are actually made. It's not just about where conditions are good, but whether you can realistically make use of them. Time becomes a constraint in the same way visibility or current does.

Trade-off

Filtering too aggressively would hide potentially interesting patterns, especially for things like night diving. Keeping everything visible but clearly labelled felt like the better approach.

What I noticed

Once time was properly accounted for, the output became easier to trust. It also reinforced how narrow some of the best windows are. A location might only line up for a couple of hours, which makes timing just as important as location.

19 April 2026

Getting It Live

launch

Once the core pieces were in place, I set up a CI/CD pipeline and deployed both the frontend and the API.

They're hosted separately, with environment configuration handled through the pipeline. The aim was to make deployments repeatable and remove any manual steps. Push to main, and changes are live within minutes.

The problem

Up to this point, everything was running locally. That works while building, but it's limiting. I wanted something I could access more casually, check on the go and use without needing to spin everything up first.

What I changed

I moved to a simple automated deployment setup. The frontend went to Vercel, the API to Railway and containerised via a custom Dockerfile after Railway's default build process caused some unexpected behaviour with the compiled output. Environment variables are managed centrally, so the same configuration is applied consistently each time.

Why this mattered

This made the project feel real. It became something I could open, check and rely on without any setup. That matters for something tied to real-world conditions that change daily. It also meant I could share it easily, and any SEO work could start compounding straight away.

Trade-off

There's upfront work in getting the pipeline and environment configuration right, and it introduces another layer where things can fail. But once it's stable, it removes far more friction than it adds.

What I noticed

Having a live version changes how you use the project. You start checking it more casually, noticing patterns over time and spotting issues you wouldn't catch in short development sessions. Iteration becomes faster as well, because changes are immediately visible in a real environment.

19 April 2026

Adding Habitat into the Mix

build

Up to this point, the model was focused almost entirely on conditions. That works to a point, but it only answers half the question. Good conditions don't mean much if you're looking in the wrong place.

I added a habitat layer using bathymetry and seabed data from EMODnet, allowing each location to be characterised by depth, slope and substrate type. This sits alongside the conditions data as a separate, independently toggleable layer rather than replacing it.

The problem

The model could tell me when things looked promising, but not where within a given area I should actually focus. In practice, that matters just as much. You can have perfect conditions over featureless sand and see very little, while a nearby reef holds fish consistently. Without habitat, everything was treated as broadly equal.

What I changed

I introduced a habitat classification for each grid cell. A regional bathymetry raster is downloaded once and sampled per cell centroid to get depth. A separate seabed substrate query returns the ground type, rock, sand, gravel or mud, which is then scored per species. The two sub-scores are combined into a single habitat suitability score, computed at startup and cached in memory.

Why this mattered

This is where the model started to feel more complete. Conditions tell you when fish are likely to be active. Habitat tells you where they're likely to be. You need both for the output to be useful. It also made the map more intuitive. Instead of just seeing scores, you start to understand why certain areas stand out.

Trade-off

The data is coarse and the classification is simplified. Seabed composition and small-scale structure aren't captured perfectly, and there's a risk of over-interpreting what is still an approximation. But even a rough signal is better than treating all locations the same.

What I noticed

Adding habitat immediately changed how I read the map. Some areas that previously looked average became more interesting, while others dropped away once it was clear they lacked structure. It also highlighted that conditions and habitat don't always align, which is part of the decision-making process.

19 April 2026

Species Change Everything

iteration

What wasn't working

Even with conditions and habitat in place, something still felt off.

The model was producing a single score per location, which assumes that all species respond to the same conditions in broadly the same way. In practice, that's not how it works. A spot that looks great for bass might be quiet for pollock. Flatfish behave differently again, and lobster are on a completely different rhythm.

What I changed

I moved away from a single generic scoring model and started introducing species-specific behaviour. Each species now has its own interpretation of the same underlying data. Temperature, current, visibility and habitat are all still used, but weighted and combined differently depending on what you're targeting.

Bass peak in warmer water and largely disappear from the shallows in winter. Pollock essentially anti-correlate on temperature, favouring the cooler conditions of spring and autumn when bass are least active. Flatfish are more forgiving across the season. The same cold-snap that drops the bass score is often exactly when pollock conditions peak, and splitting the logic makes that visible on the map rather than hiding it behind a single averaged number.

Why this mattered

This brought the model much closer to how decisions are actually made. You're rarely asking "is this a good spot?" in isolation. You're asking "is this a good spot for what I'm trying to find today?" By separating the logic per species, the same location can tell a more honest story.

Trade-off

It adds complexity quite quickly. Each species introduces its own assumptions, thresholds and edge cases, and some behaviours are better understood than others. It becomes harder to maintain, but much more representative of reality.

What I noticed

The outputs became more varied and, importantly, more believable. Instead of everything converging on the same areas, different species highlighted different parts of the map. It also made it easier to explain why something looked promising, because the reasoning was more specific.

20 April 2026

Lobster

iteration

Lobster required an entirely different approach. They're nocturnal, emerging from reef crevices after dark to forage, which means the standard conditions model doesn't apply.

The scoring formula blends a timing score with a conditions penalty. The timing score accounts for light levels based on solar position and moon illumination, water temperature, tidal flow and a spring tide multiplier. The conditions penalty applies a seasonal adjustment and a visibility proxy. The result is then blended with the habitat score to produce a single hunt score combining right place and right time.

Why this mattered

The nocturnal model has a property that makes it feel right: you never have to explicitly exclude daytime hours. The light block naturally returns near zero during daylight, so the score collapses without any conditional logic. The moon illumination suppression is a genuine behavioural signal. Lobsters reduce surface activity on bright moonlit nights, and the model reflects that.

It was also the first place in the system where two independently computed layers, conditions and habitat, were combined into a single derived score. That pattern opens up naturally for other species.

Trade-off

Lobster behaviour is harder to validate than finfish. The thresholds and weightings are grounded in what's published, but there's less data to work from. The model is a reasonable approximation rather than a confident one.

What I noticed

Having lobster as a species immediately changed how night diving sessions look in the app. A location that scores poorly during the day can look completely different after sunset under the right tidal and temperature conditions, which is exactly the kind of signal the tool was built to surface.

21 April 2026

Tidying Up the Edges

iteration

Once the core system was stable, a handful of smaller improvements landed together.

The coastline data was upgraded from a 50m to a 10m resolution dataset, which brought the grid from 73 valid cells to 104. Those extra cells weren't new dive sites, they were physically valid locations that had been invisible to the app due to geometry rounding in the coarser data. The land exclusion test was also tightened: rather than checking only whether the cell centroid falls on land, the filter now checks the majority of the cell's vertices, which handles edge cells and cove entrances more accurately.

A manual override system was added alongside. Even at 10m resolution, a handful of cells are misclassified in ways the geometry can't resolve automatically. A simple JSON file provides an include and exclude list of cell IDs applied at startup, so corrections can be made without touching the underlying algorithm.

The visualisation was updated at the same time. Discrete colour bands were replaced with a continuous gradient, so fine-grained score differences are visible at a glance rather than being flattened into buckets.

Why this mattered

None of these were individually dramatic, but together they made the tool noticeably more trustworthy to use. A map with geometric inconsistencies around the coastline is hard to reason about, and a visualisation that flattens score variation hides the nuance the model is producing. Getting these details right makes everything built on top of them feel more reliable.

What I noticed

Going from 73 to 104 cells was a 42% increase in coverage at zero scoring cost. The continuous gradient made it immediately easier to compare areas at a glance. And the override system, though rarely needed, provides a clean escape hatch for the edge cases no algorithm will ever fully resolve.

22 April 2026

Bringing It Together in the HUD

iteration

As the number of layers grew, the interface needed to keep pace.

A separate layer control panel for toggling between conditions, habitat and bathymetry was merged into the legend, making it a single unified control surface. The species selector sits at the top and drives both the conditions and habitat layers simultaneously, so you can't accidentally be looking at bass conditions and lobster habitat at the same time. The selected cell detail panel was updated to re-sync on every forecast change, so the popup stays live as you drag the slider rather than showing stale data.

Why this mattered

When layer controls and the legend live in separate components, you have to scan two places to understand what you're looking at. Merging them means the legend always directly describes the current view. It's a small thing, but it reduces the cognitive overhead of using the app significantly.

What I noticed

A single control surface makes the app feel more considered. The species selector driving both layers at once is the kind of detail that's easy to overlook when building, but immediately noticeable as a user when it's missing.