Skip to main content

Rules and Sequences

Rules and sequences let you express higher-level behaviors by composing the core spatial primitives. Instead of processing raw enter/exit events in your application code, you declare the conditions once on the engine and receive purpose-built events.

Rules

A rule watches for a spatial event — an entity entering or leaving a zone or circle — and emits a custom named event when additional filter conditions are met.

engine.defineRule(name: string, fn: (rule: RuleBuilder) => RuleConfig): this

The fluent RuleBuilder collects triggers and filters, then produces a config object when you call .emit():

engine.defineRule('fast-entry', rule =>
rule
.whenEnters('restricted-area')
.speedAbove(15)
.emit('speeding-entry')
)

When driver-7 enters restricted-area traveling faster than 15 units/s, the engine emits:

{ kind: 'rule', id: 'driver-7', name: 'fast-entry', t_ms: ..., speed: 18.2 }

Triggers

Each .when*() call adds a trigger. Multiple triggers on the same rule create an OR condition — the rule fires if any trigger matches.

MethodFires when
.whenEnters(zoneId)Entity enters a polygon zone
.whenExits(zoneId)Entity exits a polygon zone
.whenApproaches(circleId)Entity enters a circle
.whenRecedes(circleId)Entity exits a circle

Filters

Filters narrow the trigger condition. Multiple filters on the same rule create an AND condition — all must pass for the rule to fire.

MethodPasses when
.speedAbove(mps)Entity speed > threshold
.speedBelow(mps)Entity speed < threshold
.headingBetween(from, to)Entity heading is within the arc [from, to] degrees

Speed and heading are computed from position history. If the engine does not have enough history to compute them yet, speed and heading filters will not match.

Custom event data

Attach extra fields to the emitted event by passing a data object to .emit():

engine.defineRule('depot-arrival', rule =>
rule
.whenApproaches('depot-circle')
.speedBelow(5)
.emit('slow-approach', { priority: 'high' })
)
// Emits: { kind: 'rule', id: '...', name: 'depot-arrival', t_ms: ..., priority: 'high' }

Real-world example: rider dispatch

A driver needs to be in the pickup zone and moving slowly before being assigned a rider:

import { GeoEngine } from '@jamesholcombe/geo-stream'

const engine = new GeoEngine()

engine
.registerCircle('pickup-zone', -122.4194, 37.7749, 0.001) // ~100 m radius in degrees; cx = lon, cy = lat
.defineRule('ready-for-dispatch', rule =>
rule
.whenApproaches('pickup-zone')
.speedBelow(2)
.emit('driver-available')
)

const events = engine.ingest([
{ id: 'driver-12', x: -122.4194, y: 37.7749, tMs: Date.now() },
])

for (const ev of events) {
if (ev.kind === 'rule' && ev.name === 'ready-for-dispatch') {
console.log(`${ev.id} is available for pickup assignment`)
}
}

Sequences

A sequence detects when an entity completes a series of zone visits in order. The engine emits sequence_complete when all steps are checked off.

engine.defineSequence({
name: string,
steps: string[], // Zone or circle IDs to enter, in order
withinMs?: number, // Optional: reset if not completed within this window
}): this

Each step matches an enter event for polygon zones or an approach event for circles.

Real-world example: delivery route verification

A driver must visit a depot, then a loading bay, then a customer site — in that order — within 2 hours:

engine
.registerZone('depot', depotPolygon)
.registerZone('loading-bay', loadingBayPolygon)
.registerZone('customer-site', customerPolygon)
.defineSequence({
name: 'delivery-route',
steps: ['depot', 'loading-bay', 'customer-site'],
withinMs: 2 * 60 * 60 * 1000, // 2 hours
})

// When driver-5 visits all three zones in order within 2 hours:
// { kind: 'sequence_complete', id: 'driver-5', sequence: 'delivery-route', t_ms: ... }

If the driver visits customer-site before loading-bay, the sequence does not advance. If the 2-hour window expires before all steps are completed, the sequence resets and the driver must start again from depot.

Combining rules and sequences

Rules and sequences work alongside basic zone events on the same engine. A single ingest() call can produce enter, rule, and sequence_complete events simultaneously.

const engine = new GeoEngine()
.registerZone('zone-a', polygonA)
.registerZone('zone-b', polygonB)
.registerZone('zone-c', polygonC)
.defineRule('fast-a-entry', rule =>
rule.whenEnters('zone-a').speedAbove(10).emit('speeding')
)
.defineSequence({
name: 'a-to-c',
steps: ['zone-a', 'zone-b', 'zone-c'],
withinMs: 30 * 60 * 1000,
})