import { LitElement, html, unsafeHTML, css, guard, until, } from './lit-all.min.js'; import * as tfrpc from '/static/tfrpc.js'; import * as polyline from './polyline.js'; import {gpx_parse} from './gpx.js'; const k_client_id = '28276'; const k_redirect_url = 'https://tildefriends.net/~cory/gg/login'; const k_color_snow = [128, 128, 255, 255]; const k_color_ice = [160, 160, 255, 255]; const k_color_water = [0, 0, 255, 255]; const k_color_dirt = [128, 129, 130, 255]; const k_color_pavement = [32, 32, 32, 255]; const k_color_grass = [0, 255, 0, 255]; const k_color_default = [128, 128, 128, 255]; const k_store = { '๐Ÿฆž': 15, '๐Ÿ›ถ': 10, '๐Ÿ ': 10, 'โ›ฐ': 10, '๐Ÿ ': 10, }; const k_marker_snap = {x: 5, y: 4}; class GgAppElement extends LitElement { static get properties() { return { user: {type: Object}, strava: {type: Object}, activities: {type: Array}, activity: {type: Object}, world: {type: Object}, whoami: {type: String}, status: {type: Object}, tab: {type: String}, url: {type: String}, currency: {type: Number}, to_build: {type: String}, emoji_of_the_day: {type: String}, }; } constructor() { super(); this.activities = []; this.activity = {}; this.loaded_activities = []; this.placed_emojis = []; this.strava = {}; this.min_lat = Number.MAX_VALUE; this.min_lon = Number.MAX_VALUE; this.max_lat = -Number.MAX_VALUE; this.max_lon = -Number.MAX_VALUE; this.focus = undefined; this.status = undefined; this.tab = 'map'; this.load().catch(function (e) { console.log('load error', e); }); this.to_build = '๐Ÿ '; } async load() { console.log('load'); let emojis = await (await fetch('emojis.json')).json(); emojis = Object.values(emojis) .map((x) => Object.values(x)) .flat(); let today = new Date(); let date_index = today.getYear() * 356 + today.getMonth() * 31 + today.getDate(); this.emoji_of_the_day = emojis[(date_index * 123457) % emojis.length]; this.user = await tfrpc.rpc.getUser(); this.url = (await tfrpc.rpc.url()).split('?')[0]; try { await this.update_credentials(); } catch (e) { console.log('update_credentials failed', e); } try { await this.update_activities(); } catch (e) { console.log('update_activities failed', e); } await this.acquire_ssb_identity(); if (this.whoami && this.activities?.length) { await this.sync_activities(); } await this.get_activities_from_ssb(); } /* https://gist.github.com/jcouyang/632709f30e12a7879a73e9e132c0d56b?permalink_comment_id=3591045#gistcomment-3591045 */ async promise_all(promises, max_concurrent) { let index = 0; let results = []; async function exec_thread() { while (index < promises.length) { const current = index++; results[current] = await promises[current]; } } const threads = []; for (let thread = 0; thread < max_concurrent; thread++) { threads.push(exec_thread()); } await Promise.all(threads); return results; } async get_activities_from_ssb() { this.status = {text: 'loading activities'}; this.loaded_activities = []; let rows = await tfrpc.rpc.query( ` SELECT messages.author, json_extract(mention.value, '$.link') AS blob_id FROM messages_fts('"gg-activity"') JOIN messages ON messages.rowid = messages_fts.rowid, json_each(messages.content, '$.mentions') as mention WHERE json_extract(messages.content, '$.type') = 'gg-activity' AND json_extract(mention.value, '$.name') = 'activity_data' ORDER BY messages.timestamp DESC `, [] ); this.status = {text: 'loading activity data'}; let authors = rows.map((x) => x.author); let blobs = await this.promise_all( rows.map((x) => tfrpc.rpc.get_blob(x.blob_id)), 8 ); this.status = {text: 'processing activity data'}; for (let [index, blob] of blobs.entries()) { let activity; try { activity = JSON.parse(blob); } catch { activity = gpx_parse(blob); } if (activity) { activity.author = authors[index]; this.loaded_activities.push(activity); } } this.status = {text: 'calculating balance'}; rows = await tfrpc.rpc.query( ` SELECT count(*) AS currency FROM messages WHERE author = ? AND json_extract(content, '$.type') = 'gg-activity' `, [this.whoami] ); let currency = rows[0].currency; rows = await tfrpc.rpc.query( ` SELECT SUM(json_extract(content, '$.cost')) AS cost FROM messages WHERE author = ? AND json_extract(content, '$.type') = 'gg-place' `, [this.whoami] ); let spent = rows[0].cost; this.currency = currency - spent; this.status = {text: 'getting placed emojis'}; rows = await tfrpc.rpc.query(` SELECT messages.content FROM messages_fts('"gg-place"') JOIN messages ON messages.rowid = messages_fts.rowid WHERE json_extract(messages.content, '$.type') = 'gg-place' ORDER BY messages.timestamp `); for (let row of rows) { console.log(row.content); let content = JSON.parse(row.content); this.placed_emojis.push({ position: content.position, emoji: content.emoji, }); } console.log(this.placed_emojis); this.status = undefined; this.update_map(); } async sync_activities() { let ids = this.activities.map( (x) => `https://www.strava.com/activities/${x.id}` ); let missing = await tfrpc.rpc.query( ` WITH my_activities AS ( SELECT json_extract(mention.value, '$.link') AS url FROM messages, json_each(messages.content, '$.mentions') AS mention WHERE author = ? AND json_extract(messages.content, '$.type') = 'gg-activity' AND json_extract(mention.value, '$.name') = 'activity_url') SELECT from_strava.value FROM json_each(?) AS from_strava LEFT OUTER JOIN my_activities ON from_strava.value = my_activities.url WHERE my_activities.url IS NULL `, [this.whoami, JSON.stringify(ids)] ); console.log('missing = ', missing); for (let [index, row] of missing.entries()) { this.status = { text: 'syncing from strava', value: index, max: missing.length, }; let url = row.value; let id = url.match(/.*\/(\d+)/)[1]; let response = await fetch( `https://www.strava.com/api/v3/activities/${id}`, { headers: { Authorization: `Bearer ${this.strava.access_token}`, }, } ); let activity = await response.json(); let blob_id = await tfrpc.rpc.store_blob(JSON.stringify(activity)); let message = { type: 'gg-activity', mentions: [ { link: url, name: 'activity_url', }, { link: blob_id, name: 'activity_data', }, ], }; await tfrpc.rpc.appendMessage(this.whoami, message); } this.status = undefined; } async acquire_ssb_identity() { let user = await tfrpc.rpc.getUser(); if (!user?.credentials?.session?.name) { return; } let ids = await tfrpc.rpc.getIdentities(); let players = ids.length ? ( await tfrpc.rpc.query( ` SELECT author FROM messages JOIN json_each(?) ON messages.author = json_each.value WHERE json_extract(messages.content, '$.type') = 'gg-player' AND json_extract(messages.content, '$.active') ORDER BY timestamp DESC limit 1 `, [JSON.stringify(ids)] ) ).map((row) => row.author) : []; if (!players.length) { this.whoami = await tfrpc.rpc.createIdentity(); if (this.whoami) { await tfrpc.rpc.appendMessage(this.whoami, { type: 'gg-player', active: true, }); } } else { players.sort(); this.whoami = players[0]; } } async update_credentials() { let name = this.user?.credentials?.session?.name; if (!name) { return; } let shared = await tfrpc.rpc.sharedDatabaseGet(name); if (shared) { await tfrpc.rpc.databaseSet('strava', shared); await tfrpc.rpc.sharedDatabaseRemove(name); } this.strava = JSON.parse((await tfrpc.rpc.databaseGet('strava')) || '{}'); if (new Date().valueOf() / 1000 > this.strava.expires_at) { console.log( 'this looks expired', new Date().valueOf() / 1000, '>', this.strava.expires_at ); let x = await tfrpc.rpc.refresh_token(this.strava); if (x) { this.strava = x; await tfrpc.rpc.databaseSet('strava', JSON.stringify(x)); } else { this.strava = null; } } } async update_activities() { if (this?.strava?.access_token) { let response = await fetch( 'https://www.strava.com/api/v3/athlete/activities', { headers: { Authorization: `Bearer ${this.strava.access_token}`, }, } ); this.activities = await response.json(); this.activities.sort((a, b) => a.id - b.id); } } color_to_emoji(color) { const k_map = [ [k_color_snow, 'โฌœ'], [k_color_ice, '๐ŸŸฆ'], [k_color_water, '๐ŸŸฆ'], [k_color_dirt, '๐ŸŸซ'], [k_color_pavement, 'โฌ›'], [k_color_grass, '๐ŸŸฉ'], [k_color_default, '๐ŸŸง'], ]; for (let m of k_map) { if ( m[0][0] == color[0] && m[0][1] == color[1] && m[0][2] == color[2] && m[0][3] == color[3] ) { return m[1]; } } } activity_bounds(activity) { let min_lat = Number.MAX_VALUE; let min_lon = Number.MAX_VALUE; let max_lat = -Number.MAX_VALUE; let max_lon = -Number.MAX_VALUE; if (activity?.map?.polyline) { for (let pt of polyline.decode(activity.map.polyline)) { min_lat = Math.min(min_lat, pt[0]); min_lon = Math.min(min_lon, pt[1]); max_lat = Math.max(max_lat, pt[0]); max_lon = Math.max(max_lon, pt[1]); } } if (activity?.segments) { for (let segment of activity.segments) { for (let pt of segment) { min_lat = Math.min(min_lat, pt.lat); min_lon = Math.min(min_lon, pt.lon); max_lat = Math.max(max_lat, pt.lat); max_lon = Math.max(max_lon, pt.lon); } } } return { min: { lat: min_lat, lng: min_lon, }, max: { lat: max_lat, lng: max_lon, }, }; } on_click(event) { let popup = L.popup() .setLatLng(event.latlng) .setContent( `
${event.latlng.lat}, ${event.latlng.lng}
` ) .openOn(this.leaflet); } async build() { if (this.popup) { this.popup.remove(); } if (!this.marker) { return; } let latlng = this.marker.getLatLng(); let cost = k_store[this.to_build]; if (cost > this.currency) { alert('Insufficient funds.'); return; } let message = { type: 'gg-place', position: {lat: latlng.lat, lng: latlng.lng}, emoji: this.to_build, cost: cost, }; let id = await tfrpc.rpc.appendMessage(this.whoami, message); this.marker.remove(); this.placed_emojis.push({ position: {lat: latlng.lat, lng: latlng.lng}, emoji: this.to_build, }); this.currency -= cost; return this.update_map(); } on_marker_click(event) { this.popup = L.popup() .setLatLng(event.latlng) .setContent( ` ${this.to_build} (-${k_store[this.to_build]}) ` ) .openOn(this.leaflet); } snap_to_grid(latlng, fudge, zoom) { let position = this.leaflet.options.crs.latLngToPoint( latlng, zoom ?? this.leaflet.getZoom() ); position.x = Math.round(position.x / 16) * 16 + (fudge?.x ?? 0); position.y = Math.round(position.y / 16) * 16 + (fudge?.y ?? 0); position = this.leaflet.options.crs.pointToLatLng( position, zoom ?? this.leaflet.getZoom() ); return position; } on_marker_move(event) { if (!this.no_snap && this.marker) { this.no_snap = true; this.marker.setLatLng( this.snap_to_grid(this.marker.getLatLng(), k_marker_snap) ); this.no_snap = false; } } on_zoom(event) { if (this.marker) { this.marker.setLatLng( this.snap_to_grid(this.marker.getLatLng(), k_marker_snap) ); } } on_mouse_down(event) { if (this.marker) { this.marker.remove(); this.marker = undefined; } if (this.to_build) { this.marker = L.marker(this.snap_to_grid(event.latlng, k_marker_snap), { icon: L.divIcon({className: 'build-icon'}), draggable: true, }).addTo(this.leaflet); this.marker.on({click: this.on_marker_click.bind(this)}); this.marker.on({drag: this.on_marker_move.bind(this)}); } } async update_map() { let map = this.shadowRoot.getElementById('map'); if (!map || !this.loaded_activities.length) { this.leaflet = undefined; this.grid_layer = undefined; return; } if (!this.leaflet) { this.leaflet = L.map(map, { attributionControl: false, maxZoom: 16, bounceAtZoomLimits: false, }); this.leaflet.on({contextmenu: this.on_click.bind(this)}); this.leaflet.on({click: this.on_mouse_down.bind(this)}); this.leaflet.on({zoom: this.on_zoom.bind(this)}); } let self = this; let grid_layer = L.GridLayer.extend({ createTile: function (coords) { var tile = L.DomUtil.create('canvas', 'leaflet-tile'); var size = this.getTileSize(); tile.width = size.x; tile.height = size.y; var context = tile.getContext('2d'); context.font = '10pt sans'; let bounds = this._tileCoordsToBounds(coords); let degrees = 360.0 / 2 ** coords.z; let ul = bounds.getNorthWest(); let lr = bounds.getSouthEast(); let mini = document.createElement('canvas'); mini.width = Math.floor(size.x / 16.0); mini.height = Math.floor(size.y / 16.0); let mini_context = mini.getContext('2d'); let image_data = context.getImageData(0, 0, mini.width, mini.height); for (let activity of self.loaded_activities) { self.draw_activity_to_tile( image_data, mini.width, mini.height, ul, lr, activity ); } context.textAlign = 'left'; context.textBaseline = 'bottom'; for (let x = 0; x < mini.width; x++) { for (let y = 0; y < mini.height; y++) { let start = (y * mini.width + x) * 4; let pixel = self.color_to_emoji( image_data.data.slice(start, start + 4) ); if (pixel) { //context.fillRect(x * size.x / mini.width, y * size.y / mini.height, size.x / mini.width, size.y / mini.height); context.fillText( pixel, (x * size.x) / mini.width, (y * size.y) / mini.height + mini.height ); } } } for (let placed of self.placed_emojis) { let position = self.leaflet.options.crs.latLngToPoint( self.snap_to_grid(placed.position, undefined, coords.z), coords.z ); let tile_x = Math.floor(position.x / size.x); let tile_y = Math.floor(position.y / size.y); position.x = position.x - tile_x * size.x; position.y = position.y - tile_y * size.y; if (tile_x == coords.x && tile_y == coords.y) { //context.fillRect(position.x, position.y, size.x / mini.width, size.y / mini.height); context.fillText( placed.emoji, position.x, position.y + mini.height ); } } return tile; }, }); if (this.grid_layer) { this.grid_layer.redraw(); } else { this.grid_layer = new grid_layer(); this.grid_layer.addTo(this.leaflet); } for (let activity of this.loaded_activities) { let bounds = this.activity_bounds(activity); this.min_lat = Math.min(this.min_lat, bounds.min.lat); this.min_lon = Math.min(this.min_lon, bounds.min.lng); this.max_lat = Math.max(this.max_lat, bounds.max.lat); this.max_lon = Math.max(this.max_lon, bounds.max.lng); } if (this.focus) { this.leaflet.fitBounds([this.focus.min, this.focus.max]); this.focus = undefined; } else { this.leaflet.fitBounds([ [this.min_lat, this.min_lon], [this.max_lat, this.max_lon], ]); } } activity_to_color(activity) { let color = [0, 0, 0, 255]; switch (activity.sport_type) { /* Implies snow. */ case 'AlpineSki': case 'BackcountrySki': case 'NordicSki': case 'Snowshoe': case 'Snowboard': color = k_color_snow; break; /* Implies ice. */ case 'IceSkate': case 'InlineSkate': color = k_color_ice; break; /* Implies water. */ case 'Canoeing': case 'Kayaking': case 'Kitesurf': case 'Rowing': case 'Sail': case 'StandUpPaddling': case 'Surfing': case 'Swim': case 'Windsurf': color = k_color_water; break; /* Implies dirt. */ case 'EMountainBikeRide': case 'Hike': case 'MountainBikeRide': case 'RockClimbing': case 'TrailRun': color = k_color_dirt; break; /* Implies pavement. */ case 'EBikeRide': case 'GravelRide': case 'Handcycle': case 'Ride': case 'RollerSki': case 'Run': case 'Skateboard': case 'Badminton': case 'Tennis': case 'Velomobile': case 'Walk': case 'Wheelchair': color = k_color_pavement; break; /* Grass, maybe? */ case 'Golf': case 'Soccer': case 'Squash': color = k_color_grass; break; // Crossfit, // Elliptical // HighIntensityIntervalTraining // Pickleball // Pilates // Racquetball // StairStepper // TableTennis, // VirtualRide // VirtualRow // VirtualRun // WeightTraining // Workout // Yoga default: color = k_color_default; } return color; } line(image_data, x0, y0, x1, y1, value) { /* <3 https://en.wikipedia.org/wiki/Bresenham%27s_line_algorithm */ let dx = Math.abs(x1 - x0); let sx = x0 < x1 ? 1 : -1; let dy = -Math.abs(y1 - y0); let sy = y0 < y1 ? 1 : -1; let error = dx + dy; while (true) { if ( x0 >= 0 && y0 >= 0 && x0 < image_data.width && y0 < image_data.height ) { let base = (y0 * image_data.width + x0) * 4; image_data.data[base + 0] = value[0]; image_data.data[base + 1] = value[1]; image_data.data[base + 2] = value[2]; image_data.data[base + 3] = value[3]; } if (x0 == x1 && y0 == y1) { break; } let e2 = 2 * error; if (e2 >= dy) { if (x0 == x1) { break; } error += dy; x0 = Math.round(x0 + sx); } if (e2 <= dx) { if (y0 == y1) { break; } error += dx; y0 = Math.round(y0 + sy); } } } draw_activity_to_tile(image_data, width, height, ul, lr, activity) { let color = this.activity_to_color(activity); if (activity?.map?.polyline) { let last; for (let pt of polyline.decode(activity.map.polyline)) { let px = [ Math.floor((width * (pt[1] - ul.lng)) / (lr.lng - ul.lng)), Math.floor((height * (pt[0] - ul.lat)) / (lr.lat - ul.lat)), ]; if (last) { this.line(image_data, last[0], last[1], px[0], px[1], color); } last = px; } } if (activity?.segments) { for (let segment of activity.segments) { let last; for (let pt of segment) { let px = [ Math.floor((width * (pt.lon - ul.lng)) / (lr.lng - ul.lng)), Math.floor((height * (pt.lat - ul.lat)) / (lr.lat - ul.lat)), ]; if (last) { this.line(image_data, last[0], last[1], px[0], px[1], color); } last = px; } } } } async on_upload(event) { try { let file = event.srcElement.files[0]; let xml = await file.text(); let gpx = gpx_parse(xml); let blob_id = await tfrpc.rpc.store_blob(xml); console.log('blob_id = ', blob_id); console.log(gpx); let message = { type: 'gg-activity', mentions: [ { link: `https://${gpx.link}/activity/${gpx.time}`, name: 'activity_url', }, { link: blob_id, name: 'activity_data', }, ], }; console.log('id =', this.whoami, 'message = ', message); let id = await tfrpc.rpc.appendMessage(this.whoami, message); console.log('appended message', id); alert('Activity uploaded.'); await this.get_activities_from_ssb(); } catch (e) { alert(`Error: ${JSON.stringify(e, null, 2)}`); } } upload() { let input = document.createElement('input'); input.type = 'file'; input.onchange = (event) => this.on_upload(event); input.click(); } updated() { this.update_map(); } focus_map(activity) { let bounds = this.activity_bounds(activity); if (bounds.min.lat < bounds.max.lat && bounds.min.lng < bounds.max.lng) { this.tab = 'map'; this.focus = bounds; } } render_news() { return html` `; } render_store_item(item) { let [emoji, cost] = item; return html`
(this.to_build = emoji)}> ${cost} ${emoji == this.to_build ? '<-- Will be built next' : undefined}
`; } render_store() { let store = Object.assign({}, k_store); store[this.emoji_of_the_day] = 5; return html`

Store

Your balance: ${this.currency}
${Object.entries(store).map(this.render_store_item.bind(this))} `; } render() { let header; if (!this.user?.credentials?.session?.name) { header = html`
Please login to Tilde Friends, first.
`; } else if (!this.strava?.access_token) { let strava_url = `https://www.strava.com/oauth/authorize?client_id=${k_client_id}&redirect_uri=${k_redirect_url}&response_type=code&approval_prompt=auto&scope=activity%3Aread&state=${g_data.state}`; header = html`
Please login to Strava.
${this.whoami}
`; } else { header = html`

Welcome, ${this.user.credentials.session.name}

${this.whoami}

${this.status?.text} ${this.status?.value}

`; } let navigation = html` `; let content; switch (this.tab) { case 'map': content = html`
`; break; case 'news': content = this.render_news(); break; case 'friends': content = html`
Friends
`; break; case 'store': content = this.render_store(); break; } return html`
${header}
${content}
${navigation}
`; } } customElements.define('gg-app', GgAppElement);