1+ ( function ( ) {
2+ const CSV_URL = 'https://docs.google.com/spreadsheets/d/e/2PACX-1vSu9_3lyF9caXrDdlGCtO1Bg17Uhkh_L9l-REYkYVUINvrEEaVwrx1mSZ--_iKAGcJ2x8bFBzYHVU74/pub?output=csv' ;
3+ const FIELDS_URL = 'https://docs.google.com/spreadsheets/d/e/2PACX-1vSu9_3lyF9caXrDdlGCtO1Bg17Uhkh_L9l-REYkYVUINvrEEaVwrx1mSZ--_iKAGcJ2x8bFBzYHVU74/pub?gid=1583091937&single=true&output=csv' ;
4+
5+ const CONSUMERS = [
6+ { id : 'google' , label : 'Google' } ,
7+ { id : 'transitapp' , label : 'Transit' } ,
8+ { id : 'motis' , label : 'MOTIS' } ,
9+ { id : 'OpenTripPlanner' , label : 'OTP' } ,
10+ { id : 'aubin' , label : 'Aubin' } ,
11+ ] ;
12+
13+ let knownFields = [ ] ;
14+
15+ const ICONS = {
16+ check : `<svg class="gft-icon icon-success" viewBox="0 0 24 24"><path d="M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z"/></svg>` ,
17+ x : `<svg class="gft-icon icon-danger" viewBox="0 0 24 24"><path d="M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z"/></svg>` ,
18+ clock : `<svg class="gft-icon icon-warning" viewBox="0 0 24 24"><path d="M12,20A8,8 0 0,0 20,12A8,8 0 0,0 12,4A8,8 0 0,0 12,20M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22C6.47,22 2,17.53 2,12A10,10 0 0,1 12,2M12.5,7V12.25L17,14.92L16.25,16.15L11,13V7H12.5Z"/></svg>` ,
19+ partial : `<svg class="gft-icon" viewBox="0 0 24 24" fill="#FFE296"><path d="M12,2A10,10 0 0,0 12,22A10,10 0 0,0 12,2M12,4V20A8,8 0 0,1 12,4Z"/></svg>` ,
20+ planned : `<svg class="gft-icon icon-info" viewBox="0 0 24 24"><path d="M4,15V9H12V4.16L19.84,12L12,19.84V15H4Z"/></svg>` ,
21+ na : `<svg class="gft-icon" viewBox="0 0 24 24" fill="#9aa0a6"><path d="M12 2C6.47 2 2 6.47 2 12s4.47 10 10 10 10-4.47 10-10S17.53 2 12 2zm0 18c-4.41 0-8-3.59-8-8 0-1.86.64-3.57 1.71-4.93L16.93 18.29C15.57 19.36 13.86 20 12 20zm6.29-4.71L7.71 5.71C9.07 4.64 10.78 4 12 4c4.41 0 8 3.59 8 8 0 1.86-.64 3.57-1.71 4.93z"/></svg>`
22+ } ;
23+
24+ function getStatusMeta ( raw ) {
25+ const n = ( raw || '' ) . toLowerCase ( ) . trim ( ) ;
26+ if ( n . startsWith ( 'yes - for every feed' ) ) return { label : 'Every feed' , icon : ICONS . check , cls : 'icon-success' } ;
27+ if ( n . startsWith ( 'yes' ) ) return { label : 'Yes' , icon : ICONS . check , cls : 'icon-success' } ;
28+ if ( n . startsWith ( 'yes - for some feeds' ) ) return { label : 'Some feeds' , icon : ICONS . clock , cls : 'icon-warning' } ;
29+ if ( n . includes ( 'some fields are ignored' ) ) return { label : 'Partial' , icon : ICONS . partial , cls : 'icon-warning' } ;
30+ if ( n . includes ( 'partial' ) || n . includes ( 'ignored' ) ) return { label : 'Partial' , icon : ICONS . partial , cls : 'icon-neutral' } ;
31+ if ( n === 'test in progress' ) return { label : 'In progress' , icon : ICONS . clock , cls : 'icon-warning' } ;
32+ if ( n === 'integration planned' ) return { label : 'Planned' , icon : ICONS . planned , cls : 'icon-info' } ;
33+ if ( n . startsWith ( 'no' ) ) return { label : 'No' , icon : ICONS . x , cls : 'icon-danger' } ;
34+ if ( n === 'n/a' || n === 'unknown' ) return { label : 'N/A' , icon : ICONS . na , cls : 'icon-neutral' } ;
35+ return { label : '—' , icon : ICONS . na , cls : 'icon-neutral' } ;
36+ }
37+
38+ function parseCSV ( text ) {
39+ const rows = [ ] ; let row = [ ] , field = '' , inQ = false ;
40+ for ( let i = 0 ; i < text . length ; i ++ ) {
41+ const ch = text [ i ] ;
42+ if ( inQ ) { if ( ch === '"' && text [ i + 1 ] === '"' ) { field += '"' ; i ++ ; } else if ( ch === '"' ) inQ = false ; else field += ch ; }
43+ else { if ( ch === '"' ) inQ = true ; else if ( ch === ',' ) { row . push ( field ) ; field = '' ; } else if ( ch === '\n' ) { row . push ( field ) ; rows . push ( row ) ; row = [ ] ; field = '' ; } else if ( ch !== '\r' ) field += ch ; }
44+ }
45+ if ( field || row . length ) { row . push ( field ) ; rows . push ( row ) ; }
46+ return rows ;
47+ }
48+
49+ function formatContent ( text ) {
50+ if ( ! text ) return '' ;
51+ let html = text ;
52+ html = html . replace ( / \[ ( .* ?) \] \( ( .* ?) \) / g, '<a href="$2" target="_blank">$1</a>' ) ;
53+ knownFields . forEach ( field => {
54+ if ( ! field ) return ;
55+ const regex = new RegExp ( `\\b${ field } \\b` , 'g' ) ;
56+ html = html . replace ( regex , `<code class="gft-field-badge">${ field } </code>` ) ;
57+ } ) ;
58+ return html . replace ( / \n / g, '<br>' ) ;
59+ }
60+
61+ window . showGftDetails = ( title , content ) => {
62+ const m = document . getElementById ( 'gft-modal' ) ;
63+ const t = document . getElementById ( 'gft-modal-title' ) ;
64+ const b = document . getElementById ( 'gft-modal-body' ) ;
65+ if ( m && t && b ) {
66+ t . innerText = title ;
67+ b . innerHTML = formatContent ( content ) ;
68+ m . style . display = 'flex' ;
69+ }
70+ } ;
71+
72+ function renderTracker ( el , features ) {
73+ const cat = el . dataset . category ;
74+ const catRows = features . filter ( f => f . category === cat ) ;
75+ if ( ! catRows . length ) return ;
76+
77+ const tableRows = catRows . map ( f => {
78+ const cells = CONSUMERS . map ( c => {
79+ const s = f . support [ c . id ] ;
80+ const m = getStatusMeta ( s . rawStatus ) ;
81+ const hasDet = s . details && s . details . trim ( ) . length > 0 ;
82+ return `<td class="gft-cell">
83+ <div class="gft-badge ${ m . cls } ">
84+ <span class="gft-icon-anchor">${ m . icon } </span>
85+ <span class="gft-label">${ m . label } </span>
86+ ${ hasDet ? `<i class="gft-info-btn" onclick="showGftDetails('${ f . name . replace ( / ' / g, "\\'" ) } • ${ c . label } ', \`${ s . details . replace ( / ` / g, '\\`' ) . replace ( / \\ / g, '\\\\' ) } \`)">i</i>` : '' }
87+ </div>
88+ </td>` ;
89+ } ) . join ( '' ) ;
90+ return `<tr class="gft-row"><td class="gft-feature-name" title="${ f . name } ">${ f . name } </td>${ cells } </tr>` ;
91+ } ) . join ( '' ) ;
92+
93+ el . innerHTML = `
94+ <div class="gft-wrapper">
95+ <div class="gft-table-wrap">
96+ <table class="gft-table" style="width:100%; border-collapse:collapse; font-family:sans-serif;">
97+ <thead>
98+ <tr style="background:#f9fafb;">
99+ <th style="border:1px solid #eee; padding:8px;"></th>
100+ ${ CONSUMERS . map ( c => `
101+ <th style="border:1px solid #eee; padding:8px;">
102+ <div class="gft-consumer-header" style="display:flex; flex-direction:column; align-items:center; gap:4px;">
103+ <img src="/assets/${ c . id } .png" alt="" style="height:16px; width:auto;" onerror="this.style.display='none'">
104+ <span style="font-size:11px;">${ c . label } </span>
105+ </div>
106+ </th>` ) . join ( '' ) }
107+ </tr>
108+ </thead>
109+ <tbody>${ tableRows } </tbody>
110+ </table>
111+ </div>
112+ <div class="gft-footer-link">
113+ Learn more about the <a href="https://mobilitydatabase.org/gtfs-feature-tracker" target="_blank">GTFS Features Tracker</a>
114+ </div>
115+ </div>` ;
116+ }
117+
118+ function boot ( ) {
119+ if ( ! document . getElementById ( 'gft-modal' ) ) {
120+ document . body . insertAdjacentHTML ( 'beforeend' , `
121+ <div id="gft-modal" class="gft-modal">
122+ <div class="gft-modal-content">
123+ <span class="gft-modal-close" onclick="this.parentElement.parentElement.style.display='none'">×</span>
124+ <span id="gft-modal-title" class="gft-modal-title"></span>
125+ <div id="gft-modal-body" class="gft-modal-body"></div>
126+ </div>
127+ </div>` ) ;
128+ }
129+
130+ Promise . all ( [
131+ fetch ( FIELDS_URL ) . then ( r => r . text ( ) ) ,
132+ fetch ( CSV_URL ) . then ( r => r . text ( ) )
133+ ] ) . then ( ( [ fieldsText , dataText ] ) => {
134+ const fieldRows = parseCSV ( fieldsText ) ;
135+ knownFields = fieldRows . slice ( 1 ) . map ( r => r [ 0 ] ) . filter ( name => name && name . length > 2 ) ;
136+
137+ const rows = parseCSV ( dataText ) ;
138+ const headers = rows [ 0 ] ;
139+ const data = rows . slice ( 1 ) . filter ( r => r [ 0 ] ) . map ( r => {
140+ const support = { } ;
141+ CONSUMERS . forEach ( c => {
142+ const useIdx = headers . indexOf ( c . id + '_use' ) ;
143+ const detIdx = headers . indexOf ( c . id + '_details' ) ;
144+ support [ c . id ] = { rawStatus : r [ useIdx ] || '' , details : r [ detIdx ] || '' } ;
145+ } ) ;
146+ return { name : r [ 0 ] , category : r [ 1 ] , support } ;
147+ } ) ;
148+
149+ document . querySelectorAll ( '.gtfs-feature-tracker' ) . forEach ( el => renderTracker ( el , data ) ) ;
150+ } ) . catch ( err => console . error ( "GFT Error:" , err ) ) ;
151+ }
152+
153+ if ( typeof document$ !== 'undefined' ) document$ . subscribe ( boot ) ; else document . addEventListener ( 'DOMContentLoaded' , boot ) ;
154+ } ) ( ) ;
0 commit comments