Advanced Map

Building FraudMap: an Offline Electron App for Fraud Visualization

The maps repo is the most systems-heavy project in this set. I built it as a local-first fraud analysis desktop app: Electron for packaging, React for the UI, SQLite for storage, and Leaflet for the map layer. The goal was straightforward: load transaction data, derive movement and spending anomalies, and let an analyst inspect those patterns visually without depending on a cloud backend.

That design goal drove almost every architectural decision in the repo. I wanted the fraud math to be pure and testable, the persistence layer to be local and fast, the renderer to stay away from direct database access, and the app to be useful even when completely offline.

What I Built

At a product level, FraudMap does a few distinct jobs:

  • Stores customers, accounts, places, transactions, selections, config, and reports in a local SQLite database.
  • Computes fraud signals like impossible travel, dangerous amounts, atypical spending, and night activity.
  • Visualizes transactions as clustered map markers and movement edges with severity-coded arrow overlays.
  • Supports polygon filtering, state highlighting, report generation, table browsing, and live-mode replay.
  • Seeds a first-run workspace with realistic sample data so the app is immediately explorable.

That stack sounds broad, but it is intentionally layered. Each tier has a very specific responsibility.

Main Process and Boot Sequence

The Electron main process is responsible for the workspace, database, IPC handlers, and application lifecycle. I wanted the renderer to be a client of the backend, not a privileged environment with direct file-system and database access.

The boot path reflects that:

async function initialize(): Promise<void> {
  const workspacePath = getWorkspacePath();
  await ensureWorkspace(workspacePath);

  await initDatabase(workspacePath);
  initializeDefaultConfig();
  await initializeGeoData();
  registerIpcHandlers();

  const customerCount = getCustomerCount();
  if (customerCount === 0) {
    const result = await seedDatabase({
      customerCount: 10,
      txnsPerCustomer: { min: 800, max: 1200 },
      daysOfHistory: 90,
      fraudScenarioRate: 0.05
    });
    console.log(`Seeded: ${result.customersCreated} customers, ${result.transactionsCreated} transactions`);
  }
}

That flow matters because the app always comes up with a valid workspace, a migrated schema, base configuration, and enough sample data to exercise the UI. I did not want a first-run experience that consisted of a blank screen and a pile of manual setup.

The BrowserWindow setup also stays conservative: context isolation is on, window creation is blocked, external navigation is denied, and the preload bridge is the only path from React into Electron capabilities.

Database Layer

Persistence is built on better-sqlite3, and the database client is intentionally thin. It creates the connection, enables WAL mode, turns on foreign keys, runs migrations, and exposes small helpers like query, queryOne, execute, and transaction.

export async function initDatabase(workspacePath: string): Promise<void> {
  const dbPath = getDatabasePath(workspacePath);
  const isNewDb = !existsSync(dbPath);

  db = new Database(dbPath);
  db.pragma('journal_mode = WAL');
  db.pragma('foreign_keys = ON');

  await runMigrations();

  if (isNewDb) {
    console.log('New database created');
  }
}

I like this layer because it does not pretend to be an ORM. The schema is explicit, the migrations are readable, and the repo modules in src/main/db/repositories are where the app-specific query logic lives.

The schema itself covers the full analysis workflow:

  • customers
  • accounts
  • transactions
  • places
  • config
  • selections
  • reports
  • txn_metrics
  • geo_boundaries

That is enough to support both the map experience and the reporting/table views without inventing side channels.

IPC as the Application Boundary

Every meaningful operation moves through the IPC router. That includes customer lookup, transaction queries, edge calculation, places search, config reads/writes, report generation, saved selections, live-mode controls, and raw table browsing.

I used Zod schemas on the shared side so requests are validated before they hit the data layer. I also standardized success/error envelopes so the renderer can consume the backend without special-casing every handler.

This is one of the most important design decisions in the repo. It keeps the backend honest and the frontend boring, which is exactly what I want in a desktop app with real data rules.

Fraud Analytics as Pure Functions

The most valuable code in the repo lives in fraud.service.ts. That file contains the domain logic for movement classification, amount checks, atypical spending, and derived fraud scores. It does not know about React, Electron, or Leaflet. It just takes typed data and returns typed results.

computeEdges() is the clearest example:

export function computeEdges(
  transactions: Transaction[],
  config: FraudConfig
): Edge[] {
  const sorted = [...transactions].sort(
    (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
  );

  const edges: Edge[] = [];

  for (let i = 0; i < sorted.length - 1; i++) {
    const from = sorted[i];
    const to = sorted[i + 1];

    const fromHasLocation = from.lat != null && from.lon != null;
    const toHasLocation = to.lat != null && to.lon != null;
    const isPhysicalEdge = fromHasLocation && toHasLocation;

    let distanceMiles = 0;
    if (isPhysicalEdge) {
      distanceMiles = haversineDistance(from.lat!, from.lon!, to.lat!, to.lon!);
    }

    const fromTime = new Date(from.timestamp).getTime();
    const toTime = new Date(to.timestamp).getTime();
    const timeDeltaMinutes = (toTime - fromTime) / (1000 * 60);
    const impliedSpeedMph = timeDeltaMinutes > 0
      ? (distanceMiles / timeDeltaMinutes) * 60
      : 0;

    const { mode, severity, reasons } = classifyMovement(
      distanceMiles,
      timeDeltaMinutes,
      impliedSpeedMph,
      isPhysicalEdge,
      config
    );

    edges.push({ fromTxnId: from.id, toTxnId: to.id, distanceMiles, impliedSpeedMph, severityBucket: severity, reasons });
  }

  return edges;
}

That separation made the system much easier to evolve. I could adjust fraud thresholds, spend scoring methods, or reachability rings without touching the renderer at all.

Renderer State and Map Composition

On the renderer side, I used Zustand as the application state hub. The store owns the selected customer, time range, filters, loaded transactions, edges, derived metrics, animation state, selection state, layer visibility, live-mode flags, saved selections, and fraud config.

The main load path is exactly what I wanted: fetch transactions through IPC, build a Map of derived metrics keyed by transaction ID, then load edges as a separate query.

loadTransactions: async () => {
  const { selectedCustomerId, timeRange, filters } = get();
  if (!selectedCustomerId || !timeRange) return;

  set((state) => ({ loading: { ...state.loading, transactions: true } }));
  try {
    const response = await window.api.invoke<{ transactions: Transaction[]; derived: TxnDerived[] }>(
      'txn:query',
      { customerId: selectedCustomerId, timeRange, filters }
    );

    if (response.success) {
      const derivedMap = new Map<string, TxnDerived>();
      response.data.derived.forEach((d: TxnDerived) => derivedMap.set(d.txnId, d));

      set({
        transactions: response.data.transactions,
        derivedMetrics: derivedMap
      });

      get().loadEdges();
    }
  } finally {
    set((state) => ({ loading: { ...state.loading, transactions: false } }));
  }
},

That pattern keeps the renderer declarative. Components ask the store for state and trigger store actions. They do not contain ad hoc fetch logic all over the tree.

At the page level, the app is also intentionally clean:

  • FraudMapPage composes the map, legend, selection tools, and playback controls.
  • LivePage handles streaming mode.
  • ReportsPage and TablesPage expose non-map analysis surfaces.
  • FeaturesPage acts as a product/feature summary surface.

Leaflet Integration

MapView.tsx is the biggest renderer component because it has to orchestrate several concerns at once:

  • Leaflet map lifecycle
  • marker clustering
  • arrow overlays
  • popup composition
  • polygon drawing
  • pin drops
  • fly-to navigation
  • map action handling
  • animated transaction replay

I kept that component data-driven wherever possible. It gets filtered transactions and edges, turns them into layers, and rebuilds those layers when visibility or filters change. The supporting hook, useTransactionAnimation, simply reveals transactions and connecting edges as animationProgress moves from 0 to 1.

That hook is small, but it gives the replay mode a clean contract and keeps time-based behavior out of the map component itself.

Live Mode

The repo also includes a realtime fabrication service that synthesizes transactions and broadcasts them to renderer windows. That was not just for demos. It was a useful way to design the UI around incremental updates and to validate that the app could handle a stream instead of only static history.

The live service chooses customers and accounts, generates plausible merchants and locations, occasionally injects suspicious behavior, computes derived metrics, and emits those events over IPC. That gives the LivePage and report badge system something realistic to react to.

Why This Architecture Worked

FraudMap works because the boundaries are clear:

  • Electron main owns the privileged environment.
  • SQLite repos own persistence.
  • IPC owns the app contract.
  • Fraud services own domain math.
  • Zustand owns client state.
  • React + Leaflet own presentation.

If I took the repo further, I would add more automated tests around the fraud scoring rules, split MapView into more renderer modules, and tighten some of the IPC typing even further. But the current shape already reflects the engineering goal I had from the start: keep the analysis engine independent, keep the desktop shell safe, and keep the UI expressive enough to let an analyst see the story in the data.

check it out here:
https://www.youtube.com/watch?v=RYiO3RcOHjY


Leave a Reply

Your email address will not be published. Required fields are marked *