Skip to content

Demo

Here’s a full working demo of the planner that returns PlanResult regarding PlanRequest object. Make sure to proto files are implemented,

planner.js
// Visual helpers: Get readable time text
function hhmm(s) {
if (!s) return '?';
const m = /^(\d{4}-\d{2}-\d{2})?T?(\d{2}):(\d{2})/.exec(s);
return m ? `${m[2]}:${m[3]}` : s;
}
// Visual helpers: Get icon regarding transit type
function transitIcon(mode) {
switch (mode) {
case 0: return '🚆';
case 1: return '🚋';
case 2: return '🚌';
case 3: return '🚇';
case 4: return '⛴️';
case 5: return '🚈';
default: return '🚌';
}
}
function nonTransitIcon() { return '🚶'; }
// Name getter from start of leg
function placeNameFromLegStart(l) {
if (l.transitLeg) return l.transitLeg.fromEstimatedCall?.quay?.name || '';
if (l.nonTransitLeg) return l.nonTransitLeg.fromPlace?.name || '';
if (l.flexibleLeg) return l.flexibleLeg.fromPlace?.name || '';
return '';
}
// Name getter from end of leg
function placeNameFromLegEnd(l) {
if (l.transitLeg) return l.transitLeg.toEstimatedCall?.quay?.name || '';
if (l.nonTransitLeg) return l.nonTransitLeg.toPlace?.name || '';
if (l.flexibleLeg) return l.flexibleLeg.toPlace?.name || '';
return '';
}
// Summarize Trip logic visualized
function summarizeTrip(t) {
const legs = t.legs || [];
const startTime = t.aimedStartTime || legs[0]?.aimedStartTime || t.expectedStartTime;
const endTime = t.aimedEndTime || legs[legs.length - 1]?.aimedEndTime || t.expectedEndTime;
const startName = legs.length ? (placeNameFromLegStart(legs[0]) || 'Start') : 'Start';
const endName = legs.length ? (placeNameFromLegEnd(legs[legs.length - 1]) || 'End') : 'End';
const header = `🕒 ${hhmm(startTime)}${hhmm(endTime)}${t.duration ?? '?'}m • ${t.transfers ?? 0} transfer${(t.transfers||0) === 1 ? '' : 's'}`;
const sub = `📍 ${startName}${endName}`;
const legLines = legs.map((l) => {
if (l.transitLeg) {
const icon = transitIcon(l.transitLeg.mode);
const line =
l.transitLeg.line?.publicCode ||
l.transitLeg.line?.name ||
l.transitLeg.serviceJourney?.publicCode ||
'Transit';
const from = placeNameFromLegStart(l) || '';
const to = placeNameFromLegEnd(l) || '';
const plat = l.transitLeg.fromEstimatedCall?.aimedPlatform
? ` (plat. ${l.transitLeg.fromEstimatedCall.aimedPlatform})`
: '';
return ` - ${icon} ${line} ${hhmm(l.aimedStartTime)}${hhmm(l.aimedEndTime)} ${from}${plat}${to}`;
}
if (l.nonTransitLeg) {
const icon = nonTransitIcon(l.nonTransitLeg.mode);
const dist = l.distance != null ? `${(l.distance/1000).toFixed(1)} km` : '';
const dur = l.duration != null ? `${l.duration}m` : '';
const from = placeNameFromLegStart(l) || '';
const to = placeNameFromLegEnd(l) || '';
const hop = from && to ? ` ${from}${to}` : '';
return ` - ${icon} ${hhmm(l.aimedStartTime)}${hhmm(l.aimedEndTime)}${dur}${dist}${hop}`;
}
if (l.waitingLeg) {
return ` - ⏱️ wait ${hhmm(l.waitingLeg.startTime)}${hhmm(l.waitingLeg.endTime)}`;
}
if (l.flexibleLeg) {
const line = l.flexibleLeg.line?.publicCode || 'on-demand';
const from = placeNameFromLegStart(l) || '';
const to = placeNameFromLegEnd(l) || '';
return ` - 🚐 ${line} ${hhmm(l.aimedStartTime)}${hhmm(l.aimedEndTime)} ${from}${to}`;
}
return ` - leg ${hhmm(l.aimedStartTime)}${hhmm(l.aimedEndTime)} (${l.duration ?? '?'}m)`;
});
return [header, sub, ...legLines].join('\n');
}
// Client side & Connection
(async () => {
// Build types
const reqRoot = protobuf.parse(requestProto).root;
const PlanRequest = reqRoot.lookupType('planner.PlanRequest');
const UserSpeed = reqRoot.lookupEnum('planner.UserPreferences.UserSpeed').values;
const resRoot = protobuf.parse(planResultProto).root;
const PlanResult = resRoot.lookupType('planner.model.PlanResult');
// Build request
const reqObj = {
fromPlace: '52.3702,4.8952',
toPlace: '52.0907,5.1214',
timestamp: new Date().toISOString(),
arriveBy: false,
userPreferences: {
walkingSpeed: UserSpeed.AVERAGE,
bikingSpeed: UserSpeed.AVERAGE,
hasEbike: false
}
};
const err = PlanRequest.verify(reqObj);
if (err) throw new Error(err);
const binReq = PlanRequest.encode(PlanRequest.create(reqObj)).finish();
// Connect + send
const url = 'wss://api.planner.moopmoop.com/ws/plannermixer/proto';
const ws = new WebSocket(url, { headers: { 'Content-Type': 'application/protobuf' } });
ws.on('open', () => {
console.log(`[open] sending PlanRequest (${binReq.length} bytes)`);
ws.send(binReq, { binary: true });
});
ws.on('message', (data, isBinary) => {
const buf = isBinary ? data : Buffer.from(data);
const txt = tryText(buf);
if (txt) {
console.log(`[server JSON] ${txt}`);
return;
}
const result = PlanResult.decode(buf);
console.log(`timestamp: ${result.timestamp || 'n/a'}, planner: ${result.planner}, grade: ${result.grade || 'n/a'}`);
if (result.errors && result.errors.length) {
console.log('Errors:', result.errors);
}
const trips = result.trips || [];
if (!trips.length) {
console.log('(no trips)');
} else {
trips.forEach((t, i) => {
console.log(`\nTrip ${i + 1}/${trips.length}`);
console.log(summarizeTrip(t));
});
}
});
ws.on('close', (code, reason) => {
console.log(`[close] ${code} ${reason?.toString() || ''}`);
});
ws.on('error', (e) => {
console.error('[error]', e);
});
function tryText(b) {
try {
const s = b.toString('utf8');
return /^\s*[{[]/.test(s) ? s : null;
} catch {
return null;
}
}
})();