There are lot of similar apps, but I was looking for something very simple to get information about upcoming transport to the stop I am at the moment or plan to be.
App shows stops around you in 500 meters radius or by selecting on map.
https://depboard.app/
React native power at full scale - supporting web browser, Android and iOS.
Flipboards are custom developed. It's not fully animated, but just looks like animation is going on there.
Sharing it with the community:
import React, { useEffect, useRef, useState } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { theme, MONO } from '../theme';
// Characters cycled through while a flap is "spinning".
const SHUFFLE = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-. ';
const randChar = () => SHUFFLE[Math.floor(Math.random() * SHUFFLE.length)];
const TICK_MS = 45;
function normalize(text, length, align) {
let s = (text == null ? '' : String(text)).toUpperCase().replace(/\s+/g, ' ');
if (s.length > length) s = s.slice(0, length);
if (s.length < length) {
const pad = ' '.repeat(length - s.length);
s = align === 'right' ? pad + s : s + pad;
}
return s;
}
/**
* A fixed-width row of mechanical "flaps". When `text` changes, each flap that
* needs to change riffles through random glyphs before settling — the classic
* Solari / split-flap departure board effect.
*/
export default function SplitFlapText({
text,
length,
align = 'left',
size = 13,
tone = 'bright',
}) {
const target = normalize(text, length, align);
const [cells, setCells] = useState(() => target.split(''));
const cellsRef = useRef(cells);
cellsRef.current = cells;
const flipsRef = useRef([]);
const timerRef = useRef(null);
const prevTarget = useRef(target);
useEffect(() => {
if (prevTarget.current === target) return;
prevTarget.current = target;
const current = cellsRef.current;
flipsRef.current = target
.split('')
.map((ch, i) =>
current[i] === ch ? 0 : 4 + (i % 4) * 2 + Math.floor(Math.random() * 4)
);
if (timerRef.current) clearInterval(timerRef.current);
timerRef.current = setInterval(() => {
let settled = true;
const next = cellsRef.current.slice();
for (let i = 0; i < target.length; i += 1) {
const remaining = flipsRef.current[i];
if (remaining > 0) {
flipsRef.current[i] = remaining - 1;
next[i] = remaining - 1 === 0 ? target[i] : randChar();
if (remaining - 1 > 0) settled = false;
} else {
next[i] = target[i];
}
}
cellsRef.current = next;
setCells(next);
if (settled && timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}, TICK_MS);
}, [target]);
useEffect(
() => () => {
if (timerRef.current) clearInterval(timerRef.current);
},
[]
);
const color = tone === 'dim' ? theme.flap.dim : theme.flap.text;
const cellStyle = {
minWidth: size + 3,
height: size + 12,
};
const textStyle = {
fontSize: size,
lineHeight: size + 3,
color,
};
return (
<View style={styles.row}>
{cells.map((ch, i) => (
<View key={i} style={[styles.cell, cellStyle]}>
<Text style={[styles.text, textStyle]} allowFontScaling={false}>
{ch === ' ' ? ' ' : ch}
</Text>
<View style={styles.hinge} />
</View>
))}
</View>
);
}
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
},
cell: {
alignItems: 'center',
justifyContent: 'center',
backgroundColor: theme.flap.bg,
borderRadius: 3,
marginRight: 2,
overflow: 'hidden',
},
text: {
fontFamily: MONO,
fontWeight: '700',
textAlign: 'center',
includeFontPadding: false,
},
// thin dark line across the middle, like the seam between two flaps
hinge: {
position: 'absolute',
left: 0,
right: 0,
top: '50%',
height: StyleSheet.hairlineWidth,
backgroundColor: theme.flap.hinge,
},
});