diff --git a/src/runtime/components/modal.vue b/src/runtime/components/modal.vue index c3a838f..c327326 100644 --- a/src/runtime/components/modal.vue +++ b/src/runtime/components/modal.vue @@ -4,6 +4,7 @@ :active="modelValue" has-modal-card can-cancel + :fullscreen="fullscreen" @update:model-value="$emit('update:modelValue', $event)" @close="close" > @@ -34,7 +35,8 @@ export default { props: { text: { type: String, default: '+' }, title: { type: String, default: '' }, - modelValue: { type: Boolean } + modelValue: { type: Boolean }, + fullscreen: { type: Boolean } }, emits: ['input', 'update:modelValue'], data () { diff --git a/src/runtime/components/nw.ts b/src/runtime/components/nw.ts new file mode 100644 index 0000000..e0501a0 --- /dev/null +++ b/src/runtime/components/nw.ts @@ -0,0 +1,90 @@ +// adapted from https://gist.github.com/shinout/f19da7720d130f3925ac +const UP = '1' +const LEFT = '2' +const UL = '4' + +interface Options { + G: number, + P: number, + M: number +} + +interface Matrix { + [key: number]: {[key: number]: number} +} + +interface Direc { + [key: number]: {[key: number]: Array} +} + +export function NeedlemanWunsch(s1: Array, s2: Array, op: Options): {a: Array, b: Array} { + op = op || {} + const G = op.G || 2 + const P = op.P || 1 + const M = op.M || 0.5 + const mat: Matrix = {} + const direc: Direc = {} + + // initialization + for (let i = 0; i < s1.length + 1; i++) { + mat[i] = { 0: 0 } + direc[i] = { 0: [] } + for (let j = 1; j < s2.length + 1; j++) { + mat[i][j] = (i === 0) + ? 0 + : (s1[i - 1] === s2[j - 1]) ? P : M + direc[i][j] = [] + } + } + + // calculate each value + for (let i = 0; i < s1.length + 1; i++) { + for (let j = 0; j < s2.length + 1; j++) { + const newval = (i === 0 || j === 0) + ? -G * (i + j) + : Math.max(mat[i - 1][j] - G, mat[i - 1][j - 1] + mat[i][j], mat[i][j - 1] - G) + + if (i > 0 && j > 0) { + if (newval === mat[i - 1][j] - G) { direc[i][j].push(UP) } + if (newval === mat[i][j - 1] - G) { direc[i][j].push(LEFT) } + if (newval === mat[i - 1][j - 1] + mat[i][j]) { direc[i][j].push(UL) } + } else { + direc[i][j].push((j === 0) ? UP : LEFT) + } + mat[i][j] = newval + } + } + + // get result + const chars = [new Array(), new Array()] + let I = s1.length + let J = s2.length + while (I > 0 || J > 0) { + switch (direc[I][J][0]) { + case UP: + I-- + chars[0].push(s1[I]) + chars[1].push('-') + break + case LEFT: + J-- + chars[0].push('-') + chars[1].push(s2[J]) + break + case UL: + I-- + J-- + chars[0].push(s1[I]) + chars[1].push(s2[J]) + break + default: break + } + } + return { + a: chars[0].reverse(), + b: chars[1].reverse() + } + // return chars.map(function(v) { + // return v.reverse() + // }) +} diff --git a/src/runtime/components/pages/route.vue b/src/runtime/components/pages/route.vue index b62cb29..caab8a6 100644 --- a/src/runtime/components/pages/route.vue +++ b/src/runtime/components/pages/route.vue @@ -133,6 +133,9 @@ :animated="false" @update:model-value="setTab" > + + + diff --git a/src/runtime/components/route-departures-outer.vue b/src/runtime/components/route-departures-outer.vue new file mode 100644 index 0000000..6af70e5 --- /dev/null +++ b/src/runtime/components/route-departures-outer.vue @@ -0,0 +1,220 @@ + + + + + diff --git a/src/runtime/components/route-departures-table.vue b/src/runtime/components/route-departures-table.vue new file mode 100644 index 0000000..64ac520 --- /dev/null +++ b/src/runtime/components/route-departures-table.vue @@ -0,0 +1,122 @@ + + + + + diff --git a/src/runtime/components/route-departures.ts b/src/runtime/components/route-departures.ts new file mode 100644 index 0000000..eea2f5c --- /dev/null +++ b/src/runtime/components/route-departures.ts @@ -0,0 +1,256 @@ +import { NeedlemanWunsch } from './nw' + +export interface Stop { + id: number; + stop_name: string; + onestop_id: string; +} + +export interface StopTimeEvent { + scheduled: string; + estimated: string; +} + +export interface StopTime { + departure: StopTimeEvent; + timepoint: number; + stop_sequence: number; + stop: Stop; +} + +export interface Trip { + id: number; + trip_id: string; + trip_headsign: string; + stop_pattern_id: number; + direction_id: number; + stop_times: Array; + timepoints: Array; +} + +export interface StopPosition { + id: number; + visit: number; + stop?: Stop; + skipStops?: Array; +} + +export interface Route { + id: number; + trips: Array; +} + +export interface MergedPattern { + title: string; + timepointStops: Array; + trips: Array; +} + +export interface PluckedStopTime { + sequence: number; + st?: StopTime; + sp: StopPosition; +} + +export function pluckStopTimes(stopPositionPattern: Array, sts: Array): Array { + const ret = new Array() + let j = 0 + for (let i = 0; i < stopPositionPattern.length; i++) { + const sp = stopPositionPattern[i] + let found = false + if (sp.id > 0) { + for (let jj = j; jj < sts.length; jj++) { + const st = sts[jj] + if (st.stop.id === sp.id) { + ret.push({ + sequence: i, + st, + sp + }) + j = j + 1 + found = true + break + } + } + } + if (!found) { + ret.push({ + sequence: i, + sp + }) + } + } + return ret +} + +function parseHMS(value: string): number { + const a = (value || '').split(':').map((s) => { + return parseInt(s) + }) + if (a.length !== 3) { + return 0 + } + return a[0] * 3600 + a[1] * 60 + a[2] +} + +function firstStopDeparture(sts: Array): number { + if (sts.length === 0) { + return 0 + } + return parseHMS(sts[0].departure.scheduled) +} + +function hasTimepoints(sts: Array): boolean { + for (const st of sts) { + if (st.timepoint > 0) { + return true + } + } + return false +} + +export function timepointTables(trips: Array): MergedPattern { + // Filter out trips with no stop times + const filteredTrips = trips.filter((t) => { return t.stop_times.length > 0 }) + + // Check visits to all timepoints across all trips + const seenAllPatterns = new Map() + for (const t of trips) { + const seen = new Map() + const hastp = hasTimepoints(t.stop_times) + for (const st of t.stop_times) { + if (!st.timepoint && hastp) { + continue + } + // Only count first visit to this stop as a hit + const visit = seen.get(st.stop.id) ?? 0 + if (visit === 0) { + const allVisits = seenAllPatterns.get(st.stop.id) ?? 0 + seenAllPatterns.set(st.stop.id, allVisits + 1) + } + seen.set(st.stop.id, visit + 1) + } + } + + // Sort by departure from most common stop, falling back to first stop departure + const tripSortStop = new Map() + const seenAllPatternsKeys = [...seenAllPatterns.entries()].sort((a, b) => b[1] - a[1]) + if (seenAllPatternsKeys.length > 0) { + const sortStop = seenAllPatternsKeys[0][0] + for (const t of filteredTrips) { + for (const st of t.stop_times) { + // Only consider first visit to common stop + if (st.stop.id === sortStop) { + tripSortStop.set(t.id, parseHMS(st.departure.scheduled)) + break + } + } + } + } + const sortedTrips = filteredTrips.sort((a, b) => { + const at = tripSortStop.get(a.id) || firstStopDeparture(a.stop_times) + const bt = tripSortStop.get(b.id) || firstStopDeparture(b.stop_times) + return at - bt + }) + + // Convert stop patterns to StopPositions + // This is so the alignment is not confused by multiple visits to the same stop + const stopPositionPatterns = new Map>() + const stopLookup = new Map() + for (const t of sortedTrips) { + if (stopPositionPatterns.has(t.stop_pattern_id)) { + continue + } + for (const st of t.stop_times) { + stopLookup.set(st.stop.id, st.stop) + } + const stopPositionPattern = new Array() + const seen = new Map() + const hastp = hasTimepoints(t.stop_times) + for (const st of t.stop_times) { + if (!st.timepoint && hastp) { + continue + } + const visit = seen.get(st.stop.id) ?? 0 + stopPositionPattern.push({ id: st.stop.id, visit }) + seen.set(st.stop.id, visit + 1) + } + stopPositionPatterns.set(t.stop_pattern_id, stopPositionPattern) + } + + // Sort patterns by length desc + const sortedPatterns = Array.from(stopPositionPatterns.values()) + sortedPatterns.sort((a, b) => { return b.length - a.length }) + + // Merge stop patterns using Needleman-Wunsch algorithm + let mergedStops = new Array() + for (const stopPositionPattern of sortedPatterns) { + // convert to `:` + const spString = stopPositionPattern.map((s) => { return s.id + ':' + s.visit }) + // console.log('pattern:', stopPositionPattern) + // console.log(' spString:', spString) + const alignment = NeedlemanWunsch(mergedStops, spString, { G: 2, P: 1, M: -100 }) + const alignA = alignment.a + const alignB = alignment.b + // console.log(' alignA:', alignA) + // console.log(' alignB:', alignB) + + // TODO: Exclude stop pattern if there are no common stops?? + // const commonCount = 0 + // for (let i = 0; i < alignA.length; i++) { + // if (alignA[i] === alignB[i]) { + // commonCount += 1 + // } + // } + // console.log(' common count:', commonCount) + + // Merge alignA and alignB together + const mergedAlignment = Array() + for (let i = 0; i < alignA.length; i++) { + const a = alignA[i] + const b = alignB[i] + if (a !== '-') { + mergedAlignment.push(a) + } else if (b !== '-') { + mergedAlignment.push(b) + } + } + // console.log(' mergedAlignment: ', mergedAlignment) + mergedStops = mergedAlignment + } + // console.log('mergedStops:', mergedStops) + + // Convert `:` back to StopPositions + const mergedStopPositions = new Array() + for (const s of mergedStops) { + const ss = s.split(':') + const sid = Number(ss[0]) + const svisit = Number(ss[1]) + // console.log(ss, sid, svisit) + mergedStopPositions.push({ + id: sid, + visit: svisit, + stop: stopLookup.get(sid)! + }) + } + + // Sort headsigns by frequency + const headsignCount = new Map() + for (const trip of sortedTrips) { + const a = headsignCount.get(trip.trip_headsign) ?? 0 + headsignCount.set(trip.trip_headsign, a + 1) + } + const headsignUniqueKeys = [...headsignCount.keys()].sort((a, b) => { + const at = headsignCount.get(a) ?? 0 + const bt = headsignCount.get(b) ?? 0 + return bt - at + }) + const title = headsignUniqueKeys.join(', ') + + // OK + return { + title, + timepointStops: mergedStopPositions, + trips: sortedTrips + } +} diff --git a/src/runtime/plugins/filters-fn.ts b/src/runtime/plugins/filters-fn.ts index aa6056b..e8747c7 100644 --- a/src/runtime/plugins/filters-fn.ts +++ b/src/runtime/plugins/filters-fn.ts @@ -158,6 +158,10 @@ export function reformatHMS(value: string): string { return formatHMS(parseHMS(value)) } +export function reformatHM(value: string): string { + return formatHM(parseHMS(value)) +} + export function round(value: number): string { return value.toFixed(2) } diff --git a/src/runtime/plugins/filters.ts b/src/runtime/plugins/filters.ts index 360e168..8207dc2 100644 --- a/src/runtime/plugins/filters.ts +++ b/src/runtime/plugins/filters.ts @@ -2,8 +2,10 @@ import { nameSort, fromNowDate, fromNow, + reformatHM, reformatHMS, parseHMS, + formatHM, formatHMS, shortenName, formatDate, @@ -29,8 +31,10 @@ export default defineNuxtPlugin((nuxtApp) => { sanitizeFilename, fromNowDate, fromNow, + reformatHM, reformatHMS, parseHMS, + formatHM, formatHMS, shortenName, formatDate,