11 |
setValue(false)}
14 | />
15 |
19 | {children}
20 |
21 |
22 | )
23 | }
24 |
--------------------------------------------------------------------------------
/src/components/Nav.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Icon } from '@iconify/react-with-api'
3 | import { defineComponent } from 'reactivue'
4 | import { city, cityName, cities, changeCity, Cities, setAbout, setSearchOpen } from '../store'
5 | import { emitter } from '../event'
6 | import { Logo } from './Logo'
7 |
8 | export const Nav = defineComponent(
9 | () => ({ cities, city, cityName, changeCity }),
10 | ({ city, cities, cityName, changeCity }) => {
11 | return (
12 |
13 |
14 | {cityName}
15 |
16 |
25 |
26 |
27 |
setSearchOpen(true)}
30 | >
31 |
32 |
33 |
emitter.emit('track')}
36 | >
37 |
38 |
39 |
40 |
setAbout(true)}
44 | >
45 |
46 |
47 |
48 | )
49 | },
50 | )
51 |
--------------------------------------------------------------------------------
/src/components/Search.tsx:
--------------------------------------------------------------------------------
1 | import React, { useRef, useEffect } from 'react'
2 | import { Icon } from '@iconify/react-with-api'
3 | import { defineComponent } from 'reactivue'
4 | import { setSearchOpen, searchResult, searchString, setSearchString, searchOpen } from '../store'
5 | import { ListItem } from './ListItem'
6 |
7 | export const Search = defineComponent(
8 | () => ({ searchResult, searchString, searchOpen }),
9 | ({ searchResult, searchString, searchOpen }) => {
10 | const ref = useRef
(null)
11 |
12 | useEffect(() => {
13 | if (ref.current && searchOpen)
14 | ref.current?.focus()
15 | }, [])
16 |
17 | return (
18 |
22 |
23 | setSearchString(e.target.value)}
27 | placeholder="Search"
28 | className="px-3 py-4 outline-none w-full"
29 | />
30 | setSearchOpen(false)}/>
31 |
32 |
33 | {
34 | searchString
35 | ? searchResult.length
36 | ? searchResult.map(i =>
)
37 | : 无结果
38 | : 输入关键字以开始搜索
39 | }
40 |
41 |
42 | )
43 | },
44 | )
45 |
--------------------------------------------------------------------------------
/src/constants.ts:
--------------------------------------------------------------------------------
1 | export const AppName = 'Café 𝐂𝐍'
2 |
3 | export const ColorToIcon: Record = {
4 | '#50C240': 'mdi:wifi-strength-4',
5 | '#F3AE1A': 'mdi:wifi-strength-2',
6 | '#C24740': 'mdi:wifi-strength-1',
7 | '#BEBEBE': 'mdi:domain-off',
8 | }
9 |
10 | export const Colors = Object.keys(ColorToIcon)
11 |
12 | export const ignoredProperties = [
13 | '名称',
14 | '下载速度',
15 | 'shortname',
16 | 'Speedtest 链接',
17 | 'marker-color',
18 | 'marker-symbol',
19 | 'referrers',
20 | ]
21 |
--------------------------------------------------------------------------------
/src/event.ts:
--------------------------------------------------------------------------------
1 | import emitt from 'emitt'
2 |
3 | export const emitter = emitt()
4 |
--------------------------------------------------------------------------------
/src/main.css:
--------------------------------------------------------------------------------
1 | @import 'tailwindcss/base';
2 | @import 'tailwindcss/components';
3 | @import 'tailwindcss/utilities';
4 |
5 | html, body, #app {
6 | height: 100vh;
7 | width: 100vw;
8 | margin: 0;
9 | padding: 0;
10 | }
11 |
12 | .mapboxgl-ctrl-geolocate {
13 | display: none !important;
14 | }
--------------------------------------------------------------------------------
/src/main.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { render } from 'react-dom'
3 | import { defineComponent } from 'reactivue'
4 | import { city, data, filteredGeo, filter, setCurrent, current, about, setAbout, searchOpen, setSearchOpen } from './store'
5 | import { Nav } from './components/Nav'
6 | import { Map } from './components/Map'
7 | import { FloatControl } from './components/FloatControl'
8 | import { Modal } from './components/Modal'
9 | import { Detail } from './components/Detail'
10 | import { About } from './components/About'
11 | import { Search } from './components/Search'
12 | import './main.css'
13 |
14 | const App = defineComponent(
15 | () => ({ city, data, filteredGeo, filter, current, about, searchOpen }),
16 | ({ city, data, filteredGeo, filter, current, about, searchOpen }) => {
17 | return (
18 |
19 |
20 |
21 |
23 |
24 |
25 |
setCurrent(null)}>
26 | {current ? : null }
27 |
28 |
setAbout(false)}>
29 |
30 |
31 |
setSearchOpen(false)}>
32 |
33 |
34 |
35 | )
36 | },
37 | )
38 |
39 | render(, document.getElementById('app'))
40 |
--------------------------------------------------------------------------------
/src/store.ts:
--------------------------------------------------------------------------------
1 | import { computed, Ref, ref } from 'reactivue'
2 | import { useStorage } from '@vueuse/core'
3 | import Fuse from 'fuse.js'
4 | import { CafeShop } from './types'
5 | import raw from './data.json'
6 |
7 | export const rawData = Object.freeze(raw)
8 | export const geo = Object.freeze({
9 | type: 'FeatureCollection',
10 | features: Object.values(rawData).flatMap(i => i.data.features as any[]),
11 | })
12 | export const shops = Object.freeze(geo.features.map(i => ({
13 | ...i,
14 | coordinates: i.geometry.coordinates,
15 | }) as CafeShop))
16 | export const fuseByName = new Fuse(shops, {
17 | includeScore: false,
18 | keys: [
19 | ['properties', '名称'],
20 | ],
21 | })
22 | export const fuseByReferrers = new Fuse(shops, {
23 | includeScore: false,
24 | keys: [
25 | ['properties', 'referrers'],
26 | ],
27 | })
28 |
29 | export type Cities = keyof typeof raw
30 |
31 | export const city = useStorage('cafe-cn-city', 'shanghai') as Ref
32 | export const filter = useStorage('cafe-cn-filter', 'all')
33 | export const loc = ref<[number, number] | null>(null)
34 | export const about = ref(false)
35 | export const searchOpen = ref(false)
36 | export const searchString = ref('')
37 |
38 | export const data = computed(() => rawData[city.value])
39 |
40 | export const filteredGeo = computed(() => {
41 | if (filter.value === 'all') {
42 | return geo
43 | }
44 | else {
45 | return {
46 | ...geo,
47 | features: (geo.features as any[])
48 | .filter(i => i.properties['marker-color'] === filter.value),
49 | }
50 | }
51 | })
52 |
53 | export const searchResult = computed(() => {
54 | if (searchString.value.startsWith('@'))
55 | return fuseByReferrers.search(searchString.value).map(i => i.item)
56 | else
57 | return fuseByName.search(searchString.value).map(i => i.item)
58 | })
59 |
60 | export const current = ref(null)
61 |
62 | export const cityName = computed(() => data.value.name)
63 | export const cities = Object.entries(raw)
64 |
65 | export const setFilter = (v: string) => filter.value === v ? filter.value = 'all' : filter.value = v
66 | export const changeCity = (v: Cities) => city.value = v
67 | export const setCurrent = (v: CafeShop | null) => current.value = v
68 | export const setLoc = (v: [number, number] | null) => loc.value = v
69 | export const setAbout = (v: boolean) => about.value = v
70 | export const setSearchOpen = (v: boolean) => searchOpen.value = v
71 | export const setSearchString = (str: string) => searchString.value = str
72 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | export interface CafeShop {
2 | coordinates: [number, number]
3 | properties: Record
4 | }
5 |
--------------------------------------------------------------------------------
/src/utils/distance.ts:
--------------------------------------------------------------------------------
1 | import { loc } from '../store'
2 |
3 | export const getDistanceFromMe = (coords: [number, number]) => {
4 | if (!loc.value)
5 | return null
6 |
7 | const [lat1, lon1] = coords
8 | const [lat2, lon2] = loc.value
9 | if ((lat1 === lat2) && (lon1 === lon2)) {
10 | return 0
11 | }
12 | else {
13 | const radlat1 = Math.PI * lat1 / 180
14 | const radlat2 = Math.PI * lat2 / 180
15 | const theta = lon1 - lon2
16 | const radtheta = Math.PI * theta / 180
17 | let dist = Math.sin(radlat1) * Math.sin(radlat2) + Math.cos(radlat1) * Math.cos(radlat2) * Math.cos(radtheta)
18 | if (dist > 1)
19 | dist = 1
20 |
21 | dist = Math.acos(dist)
22 | dist = dist * 180 / Math.PI
23 | dist = dist * 60 * 1.1515
24 | const km = dist * 1.609344
25 |
26 | if (km > 1)
27 | return `${+km.toFixed(2)} km`
28 | else
29 | return `${Math.round(km * 1000)} m`
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/utils/parseShop.ts:
--------------------------------------------------------------------------------
1 | import { CafeShop } from '../types'
2 | import { ignoredProperties } from '../constants'
3 | import { getDistanceFromMe } from './distance'
4 |
5 | export const parseShop = (shop: CafeShop) => {
6 | const { properties, coordinates } = shop
7 |
8 | const location1 = coordinates.join(',')
9 | const location2 = coordinates.slice().reverse().join(',')
10 | const distance = getDistanceFromMe(coordinates)
11 |
12 | const table = Object.entries(properties)
13 | .filter(([k]) => !ignoredProperties.includes(k))
14 |
15 | return {
16 | shop,
17 | properties,
18 | coordinates,
19 | color: properties['marker-color'],
20 | name: properties['名称'],
21 | speed: properties['下载速度'],
22 | speedtest: properties['Speedtest 链接'],
23 | referrers: properties.referrers as any as string[],
24 | location1,
25 | location2,
26 | table,
27 | distance,
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/src/utils/time.ts:
--------------------------------------------------------------------------------
1 | export function fromNow(to: number, now = +new Date()) {
2 | const msPerMinute = 60 * 1000
3 | const msPerHour = msPerMinute * 60
4 | const msPerDay = msPerHour * 24
5 | const msPerMonth = msPerDay * 30
6 | const msPerYear = msPerDay * 365
7 |
8 | const elapsed = now - to
9 |
10 | if (elapsed < msPerMinute)
11 | return `${Math.round(elapsed / 1000)} seconds ago`
12 |
13 | else if (elapsed < msPerHour)
14 | return `${Math.round(elapsed / msPerMinute)} minutes ago`
15 |
16 | else if (elapsed < msPerDay)
17 | return `${Math.round(elapsed / msPerHour)} hours ago`
18 |
19 | else if (elapsed < msPerMonth)
20 | return `${Math.round(elapsed / msPerDay)} days ago`
21 |
22 | else if (elapsed < msPerYear)
23 | return `${Math.round(elapsed / msPerMonth)} months ago`
24 | else
25 | return `${Math.round(elapsed / msPerYear)} years ago`
26 | }
27 |
--------------------------------------------------------------------------------
/src/window.d.ts:
--------------------------------------------------------------------------------
1 | declare interface Window {
2 | mapboxgl: any
3 | map: any
4 | MapboxLanguage: any
5 | }
6 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | purge: {
3 | content: [
4 | './index.html',
5 | './src/**/*.vue',
6 | './src/**/*.js',
7 | './src/**/*.ts',
8 | ],
9 | },
10 | theme: {
11 | extend: {
12 | opacity: {
13 | 10: '0.1',
14 | },
15 | },
16 | },
17 | }
18 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "lib": ["DOM", "DOM.Iterable", "ESNext"],
5 | "types": [],
6 | "allowJs": false,
7 | "skipLibCheck": false,
8 | "esModuleInterop": false,
9 | "allowSyntheticDefaultImports": true,
10 | "strict": true,
11 | "forceConsistentCasingInFileNames": true,
12 | "module": "ESNext",
13 | "moduleResolution": "Node",
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "noEmit": true,
17 | "jsx": "react",
18 | },
19 | "include": ["src"]
20 | }
21 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import * as reactPlugin from 'vite-plugin-react'
2 | import type { UserConfig } from 'vite'
3 |
4 | const config: UserConfig = {
5 | jsx: 'react',
6 | plugins: [reactPlugin],
7 | alias: {
8 | vue: 'reactivue',
9 | 'vue-demi': 'reactivue',
10 | '@vue/runtime-dom': 'reactivue',
11 | },
12 | optimizeDeps: {
13 | exclude: [
14 | 'dayjs',
15 | 'reactivue',
16 | 'react-mapbox-gl',
17 | ],
18 | },
19 | }
20 |
21 | export default config
22 |
--------------------------------------------------------------------------------