├── src ├── App.css ├── assets │ └── fonts │ │ └── MarkPro-Medium.ttf ├── lambda │ ├── resolvers │ │ ├── index.js │ │ └── Query │ │ │ ├── country.js │ │ │ ├── search.js │ │ │ ├── index.js │ │ │ ├── region.js │ │ │ ├── district.js │ │ │ ├── commune.js │ │ │ └── fokontany.js │ ├── schema │ │ ├── base.js │ │ ├── country.js │ │ ├── geometry.js │ │ ├── index.js │ │ ├── search.js │ │ ├── region.js │ │ ├── district.js │ │ ├── commune.js │ │ └── fokontany.js │ ├── graphql.js │ ├── async-dadjoke.js │ ├── formater.js │ └── repository.js ├── App.test.js ├── index.js ├── App.js ├── logo.svg ├── components │ ├── Popover │ │ ├── PopoverList.js │ │ └── index.js │ ├── Commune │ │ └── index.js │ ├── Region │ │ └── index.js │ ├── District │ │ └── index.js │ └── Fokontany │ │ └── index.js ├── serviceWorker.js └── index.css ├── preview.png ├── preview-api.png ├── public ├── favicon.ico ├── manifest.json └── index.html ├── .env.example ├── netlify.toml ├── .gitignore ├── LICENSE ├── package.json └── README.md /src/App.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsirysndr/madagascar-explorer/HEAD/preview.png -------------------------------------------------------------------------------- /preview-api.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsirysndr/madagascar-explorer/HEAD/preview-api.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsirysndr/madagascar-explorer/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /src/assets/fonts/MarkPro-Medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsirysndr/madagascar-explorer/HEAD/src/assets/fonts/MarkPro-Medium.ttf -------------------------------------------------------------------------------- /src/lambda/resolvers/index.js: -------------------------------------------------------------------------------- 1 | 2 | import{ Query } from './Query'; 3 | 4 | const resolvers = { 5 | Query 6 | } 7 | 8 | export default resolvers; -------------------------------------------------------------------------------- /src/lambda/resolvers/Query/country.js: -------------------------------------------------------------------------------- 1 | 2 | export const Country = { 3 | country: (parent, args, context) => { 4 | return {}; 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /src/lambda/resolvers/Query/search.js: -------------------------------------------------------------------------------- 1 | import { search } from '../../repository'; 2 | 3 | export const Search = { 4 | search: (parent, { keyword }, context) => { 5 | return search(context, keyword); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/lambda/schema/base.js: -------------------------------------------------------------------------------- 1 | import { queryType, mutationType } from 'nexus' 2 | 3 | export const Query = queryType({ 4 | definition() {} 5 | }) 6 | 7 | export const Mutation = mutationType({ 8 | definition() {} 9 | }) 10 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | REACT_APP_MAPBOX_ACCESS_TOKEN=YOUR_MAPBOX_ACCESS_TOKEN 2 | REACT_APP_API_URL=http://localhost:9000/.netlify/functions/graphql 3 | REACT_APP_MAP_STYLE=mapbox://styles/tsiry/cjd482wz13bzx2rk3u0lb6ez3 4 | FAUNADB_SECRET=YOUR_FAUNADB_SECRET 5 | NODE_ENV=development 6 | -------------------------------------------------------------------------------- /src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import App from './App' 4 | 5 | it('renders without crashing', () => { 6 | const div = document.createElement('div') 7 | ReactDOM.render(, div) 8 | ReactDOM.unmountComponentAtNode(div) 9 | }) 10 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | command = "yarn build" # the command you run to build this file 3 | functions = "built-lambda" # netlify-lambda builds to this folder AND Netlify reads functions from here 4 | publish = "build" # create-react-app builds to this folder, Netlify should serve all these files statically 5 | 6 | [context.branch-deploy.environment] 7 | NODE_ENV = "development" -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /src/lambda/resolvers/Query/index.js: -------------------------------------------------------------------------------- 1 | import { Commune } from './commune'; 2 | import { Country } from './country'; 3 | import { District } from './district'; 4 | import { Fokontany } from './fokontany'; 5 | import { Region } from './region'; 6 | import { Search } from './search'; 7 | 8 | export const Query = { 9 | ...Commune, 10 | ...Country, 11 | ...District, 12 | ...Fokontany, 13 | ...Region, 14 | ...Search, 15 | } 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | /built-lambda 12 | 13 | /.netlify 14 | 15 | # misc 16 | .DS_Store 17 | # .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | 26 | .env -------------------------------------------------------------------------------- /src/lambda/graphql.js: -------------------------------------------------------------------------------- 1 | import { ApolloServer } from 'apollo-server-lambda'; 2 | import faunadb from 'faunadb'; 3 | import schema from './schema'; 4 | 5 | const client = new faunadb.Client({ secret: process.env.FAUNADB_SECRET }) 6 | 7 | const server = new ApolloServer({ 8 | schema, 9 | context: () => { 10 | return { client } 11 | } 12 | }); 13 | 14 | exports.handler = server.createHandler({ 15 | cors: { 16 | origin: true, 17 | credentials: true, 18 | }, 19 | }); 20 | -------------------------------------------------------------------------------- /src/lambda/schema/country.js: -------------------------------------------------------------------------------- 1 | import { objectType, extendType } from 'nexus'; 2 | import { MultiPolygon } from './geometry'; 3 | import resolvers from '../resolvers'; 4 | 5 | export const Country = objectType({ 6 | name: 'Country', 7 | definition(t) { 8 | t.id('id', { nullable: true }) 9 | t.string('name', { nullable: true }) 10 | t.string('code', { nullable: true }) 11 | t.field('geometry', { type: MultiPolygon, nullable: true }) 12 | } 13 | }) 14 | 15 | export const CountryQuery = extendType({ 16 | type: 'Query', 17 | definition(t) { 18 | t.field('country', { 19 | type: Country, 20 | nullable: true, 21 | resolve: resolvers.Query.country 22 | }) 23 | } 24 | }) 25 | -------------------------------------------------------------------------------- /src/lambda/async-dadjoke.js: -------------------------------------------------------------------------------- 1 | // example of async handler using async-await 2 | // https://github.com/netlify/netlify-lambda/issues/43#issuecomment-444618311 3 | 4 | import axios from "axios" 5 | export async function handler(event, context) { 6 | try { 7 | const response = await axios.get("https://icanhazdadjoke.com", { headers: { Accept: "application/json" } }) 8 | const data = response.data 9 | return { 10 | statusCode: 200, 11 | body: JSON.stringify({ msg: data.joke }) 12 | } 13 | } catch (err) { 14 | console.log(err) // output to netlify function log 15 | return { 16 | statusCode: 500, 17 | body: JSON.stringify({ msg: err.message }) // Could be a custom message or object i.e. JSON.stringify(err) 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/lambda/schema/geometry.js: -------------------------------------------------------------------------------- 1 | import { objectType, extendType } from 'nexus'; 2 | 3 | export const Polygon = objectType({ 4 | name: 'Polygon', 5 | definition(t) { 6 | t.string('type', { nullable: true }) 7 | t.float('coordinates', { list: [ false, false, false ], nullable: true }) 8 | } 9 | }) 10 | 11 | export const MultiPolygon = objectType({ 12 | name: 'MultiPolygon', 13 | definition(t) { 14 | t.string('type', { nullable: true }) 15 | t.float('coordinates', { list: [false, false, false, false], nullable: true }) 16 | } 17 | }) 18 | 19 | export const Geometry = objectType({ 20 | name: 'Geometry', 21 | definition(t) { 22 | t.string('type', { nullable: true }) 23 | t.field('polygon', { type: Polygon, nullable: true }) 24 | t.field('multipolygon', { type: MultiPolygon, nullable: true }) 25 | } 26 | }) 27 | -------------------------------------------------------------------------------- /src/lambda/schema/index.js: -------------------------------------------------------------------------------- 1 | import { makeSchema } from 'nexus'; 2 | import { Query } from './base'; 3 | import { Commune, CommuneQuery } from './commune'; 4 | import { Country, CountryQuery } from './country'; 5 | import { District, DistrictQuery } from './district'; 6 | import { Fokontany, FokontanyQuery } from './fokontany'; 7 | import { Polygon, MultiPolygon } from './geometry'; 8 | import { Region, RegionQuery } from './region'; 9 | import { Results, SearchQuery } from './search'; 10 | 11 | export default makeSchema({ 12 | types: [ 13 | Query, 14 | Commune, 15 | CommuneQuery, 16 | Country, 17 | CountryQuery, 18 | District, 19 | DistrictQuery, 20 | Fokontany, 21 | FokontanyQuery, 22 | Polygon, 23 | MultiPolygon, 24 | Region, 25 | RegionQuery, 26 | Results, 27 | SearchQuery, 28 | ], 29 | }); 30 | -------------------------------------------------------------------------------- /src/lambda/schema/search.js: -------------------------------------------------------------------------------- 1 | import resolvers from '../resolvers'; 2 | import { extendType, stringArg, objectType } from 'nexus'; 3 | import { Region } from './region'; 4 | import { District } from './district'; 5 | import { Commune } from './commune'; 6 | import { Fokontany } from './fokontany'; 7 | 8 | export const Results = objectType({ 9 | name: 'results', 10 | definition(t) { 11 | t.field('regions', { list:[false], type: Region, nullable: true }) 12 | t.field('districts', { list:[false], type: District, nullable: true }) 13 | t.field('communes', { list:[false], type: Commune, nullable: true }) 14 | t.field('fokontany', { list:[false], type: Fokontany, nullable: true }) 15 | } 16 | }) 17 | 18 | export const SearchQuery = extendType({ 19 | type: 'Query', 20 | definition(t) { 21 | t.field('search', { 22 | args: { 23 | keyword: stringArg({ required: true }), 24 | }, 25 | type: Results, 26 | nullable: true, 27 | resolve: resolvers.Query.search, 28 | }) 29 | } 30 | }) 31 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import 'antd/dist/antd.css' 4 | import './index.css' 5 | import App from './App' 6 | import Region from './components/Region' 7 | import District from './components/District' 8 | import Commune from './components/Commune' 9 | import Fokontany from './components/Fokontany' 10 | import * as serviceWorker from './serviceWorker' 11 | import ApolloClient from 'apollo-boost' 12 | import { ApolloProvider } from '@apollo/react-hooks' 13 | import { HashRouter, Route } from 'react-router-dom' 14 | 15 | const client = new ApolloClient({ 16 | uri: process.env.REACT_APP_API_URL 17 | }) 18 | 19 | ReactDOM.render( 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | , document.getElementById('root')) 30 | 31 | // If you want your app to work offline and load faster, you can change 32 | // unregister() to register() below. Note this comes with some pitfalls. 33 | // Learn more about service workers: http://bit.ly/CRA-PWA 34 | serviceWorker.unregister() 35 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import DeckGL from '@deck.gl/react' 3 | import MapGL from 'react-map-gl' 4 | import Popover from './components/Popover' 5 | import './App.css' 6 | 7 | // Set your mapbox access token here 8 | const MAPBOX_ACCESS_TOKEN = process.env.REACT_APP_MAPBOX_ACCESS_TOKEN 9 | const MAPBOX_STYLE = process.env.REACT_APP_MAP_STYLE 10 | 11 | const App = (props) => { 12 | const layers = [ 13 | // new LineLayer({id: 'line-layer', data}) 14 | ] 15 | const [expanded, setExpanded] = useState(false) 16 | const popoverClass = `popover ${expanded ? 'expand' : 'shrink'}` 17 | const [viewport, setViewport] = useState({ 18 | longitude: 47.52186, 19 | latitude: -18.91449, 20 | zoom: 11.97, 21 | bearing: 0, 22 | pitch: 30 23 | }) 24 | 25 | return ( 26 |
27 | setExpanded(false)} 32 | > 33 | setViewport(value)} 41 | /> 42 | 43 | 44 |
45 | ) 46 | } 47 | 48 | export default App 49 | -------------------------------------------------------------------------------- /src/lambda/schema/region.js: -------------------------------------------------------------------------------- 1 | import { objectType, extendType, intArg, idArg } from 'nexus'; 2 | import { Polygon, Geometry } from './geometry'; 3 | import resolvers from '../resolvers'; 4 | 5 | export const Region = objectType({ 6 | name: 'Region', 7 | definition(t) { 8 | t.id('id', { nullable: true }) 9 | t.string('name', { nullable: true }) 10 | t.string('province', { nullable: true }) 11 | t.string('code', { nullable: true }) 12 | t.field('geometry', { type: Geometry, nullable: true }) 13 | } 14 | }) 15 | 16 | export const RegionList = objectType({ 17 | name: 'RegionList', 18 | definition(t) { 19 | t.field('data', { list: [false], type: Region, nullable: true }) 20 | t.field('after', { type: Region, nullable: true }) 21 | } 22 | }) 23 | 24 | export const RegionQuery = extendType({ 25 | type: 'Query', 26 | definition(t) { 27 | t.field('region', { 28 | args: { 29 | id: idArg({ required: true }) 30 | }, 31 | type: Region, 32 | nullable: true, 33 | resolve: resolvers.Query.region, 34 | }) 35 | t.field('regions', { 36 | args: { 37 | after: idArg({ required: false }), 38 | size: intArg({ required: false }) 39 | }, 40 | type: RegionList, 41 | nullable: true, 42 | resolve: resolvers.Query.regions, 43 | }) 44 | t.int('countRegions', { 45 | nullable: true, 46 | resolve: resolvers.Query.countRegions 47 | }) 48 | } 49 | }) 50 | 51 | -------------------------------------------------------------------------------- /src/lambda/formater.js: -------------------------------------------------------------------------------- 1 | export const formatFokontanyItem = (item) => { 2 | if (!item) { 3 | return null 4 | } 5 | return { 6 | id: item[0].value.id, 7 | name: item[1], 8 | code: item[5], 9 | province: item[1], 10 | region: item[4], 11 | district: item[3], 12 | commune: item[2], 13 | } 14 | } 15 | 16 | export const formatCommune = (item) => { 17 | if (!item) { 18 | return null 19 | } 20 | return { 21 | id: item[0].value.id, 22 | name: item[1], 23 | code: item[5], 24 | province: item[4], 25 | region: item[3], 26 | district: item[2], 27 | } 28 | } 29 | 30 | export const formatDistrict = (item) => { 31 | if (!item) { 32 | return null 33 | } 34 | return { 35 | id: item[0].value.id, 36 | name: item[1], 37 | code: item[4], 38 | province: item[3], 39 | region: item[2] 40 | } 41 | } 42 | 43 | export const formatRegion = (item) => { 44 | if (!item) { 45 | return null 46 | } 47 | return { 48 | id: item[0].value.id, 49 | name: item[1], 50 | code: item[3], 51 | province: item[2], 52 | } 53 | } 54 | 55 | export const formatRegions = (result) => ( 56 | result.map(item => formatRegion(item)) 57 | ); 58 | 59 | export const formatDistricts = (result) => ( 60 | result.map(item => formatDistrict(item)) 61 | ); 62 | 63 | export const formatCommunes = (result) => ( 64 | result.map(item => formatCommune(item)) 65 | ); 66 | 67 | export const formatFokontany = (result) => ( 68 | result.map(item => formatFokontanyItem(item)) 69 | ); 70 | -------------------------------------------------------------------------------- /src/lambda/schema/district.js: -------------------------------------------------------------------------------- 1 | import { objectType, extendType, intArg, idArg } from 'nexus'; 2 | import { Geometry } from './geometry'; 3 | import resolvers from '../resolvers'; 4 | 5 | export const District = objectType({ 6 | name: 'District', 7 | definition(t) { 8 | t.id('id', { nullable: true }) 9 | t.string('name', { nullable: true }) 10 | t.string('province', { nullable: true }) 11 | t.string('code', { nullable: true }) 12 | t.string('region', { nullable: true }) 13 | t.field('geometry', { type: Geometry, nullable: true }) 14 | } 15 | }) 16 | 17 | export const DistrictList = objectType({ 18 | name: 'DistrictList', 19 | definition(t) { 20 | t.field('data', { list: [false], type: District, nullable: true }) 21 | t.field('after', { type: District, nullable: true }) 22 | } 23 | }) 24 | 25 | export const DistrictQuery = extendType({ 26 | type: 'Query', 27 | definition(t) { 28 | t.field('district', { 29 | args: { 30 | id: idArg({ required: true }) 31 | }, 32 | type: District, 33 | nullable: true, 34 | resolve: resolvers.Query.district 35 | }) 36 | t.field('districts', { 37 | args: { 38 | after: idArg({ required: false }), 39 | size: intArg({ required: false }) 40 | }, 41 | type: DistrictList, 42 | nullable: true, 43 | resolve: resolvers.Query.districts 44 | }) 45 | t.int('countDistricts', { 46 | nullable: true, 47 | resolve: resolvers.Query.countDistricts 48 | }) 49 | } 50 | }) 51 | -------------------------------------------------------------------------------- /src/lambda/repository.js: -------------------------------------------------------------------------------- 1 | import alasql from 'alasql/dist/alasql'; 2 | import { query as q } from 'faunadb'; 3 | import { formatCommunes, formatFokontany, formatDistricts, formatRegions } from './formater'; 4 | 5 | export const search = async (context, keyword) => { 6 | const size = 100; 7 | const _fokontany = await context.client.query( 8 | q.Paginate( 9 | q.Match(q.Index('fokontany_sort_by_ref')), 10 | { size } 11 | ) 12 | ); 13 | const _communes = await context.client.query( 14 | q.Paginate( 15 | q.Match(q.Index('communes_sort_by_ref')), 16 | { size } 17 | ) 18 | ); 19 | const _districts = await context.client.query( 20 | q.Paginate( 21 | q.Match(q.Index('districts_sort_by_ref')), 22 | { size } 23 | ) 24 | ); 25 | const _regions = await context.client.query( 26 | q.Paginate( 27 | q.Match(q.Index('regions_sort_by_ref')), 28 | ) 29 | ); 30 | 31 | const regions = alasql(`SELECT * FROM ? WHERE LOWER(name) LIKE '%${keyword}%' ORDER BY name ASC`, [formatRegions(_regions.data)]); 32 | const districts = alasql(`SELECT * FROM ? WHERE LOWER(name) LIKE '%${keyword}%' ORDER BY name ASC`, [formatDistricts(_districts.data)]); 33 | const communes = alasql(`SELECT * FROM ? WHERE LOWER(name) LIKE '%${keyword}%' ORDER BY name ASC`, [formatCommunes(_communes.data)]); 34 | const fokontany = alasql(`SELECT * FROM ? WHERE LOWER(name) LIKE '%${keyword}%' ORDER BY name ASC`, [formatFokontany(_fokontany.data)]); 35 | 36 | return { regions, districts, communes, fokontany }; 37 | } 38 | -------------------------------------------------------------------------------- /src/lambda/schema/commune.js: -------------------------------------------------------------------------------- 1 | import { objectType, extendType, intArg, idArg } from 'nexus'; 2 | import { Geometry } from './geometry'; 3 | import resolvers from '../resolvers'; 4 | 5 | export const Commune = objectType({ 6 | name: 'Commune', 7 | definition(t) { 8 | t.id('id', { nullable: true }) 9 | t.string('name', { nullable: true }) 10 | t.string('province', { nullable: true }) 11 | t.string('code', { nullable: true }) 12 | t.string('district', { nullable: true }) 13 | t.string('region', { nullable: true }) 14 | t.field('geometry', { type: Geometry, nullable: true }) 15 | } 16 | }) 17 | 18 | export const CommuneList = objectType({ 19 | name: 'CommuneList', 20 | definition(t) { 21 | t.field('data', { list: [false], type: Commune, nullable: true }) 22 | t.field('after', { type: Commune, nullable: true }) 23 | } 24 | }) 25 | 26 | export const CommuneQuery = extendType({ 27 | type: 'Query', 28 | definition(t) { 29 | t.field('commune', { 30 | args: { 31 | id: idArg({ required: true }) 32 | }, 33 | type: Commune, 34 | nullable: true, 35 | resolve: resolvers.Query.commune, 36 | }) 37 | t.field('communes', { 38 | args: { 39 | after: idArg({ required: false }), 40 | size: intArg({ required: false }) 41 | }, 42 | type: CommuneList, 43 | nullable: true, 44 | resolve: resolvers.Query.communes, 45 | }) 46 | t.int('countCommunes', { 47 | nullable: true, 48 | resolve: resolvers.Query.countCommunes 49 | }) 50 | } 51 | }) 52 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2020, Tsiry Sandratraina 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /src/lambda/resolvers/Query/region.js: -------------------------------------------------------------------------------- 1 | import { query as q } from 'faunadb'; 2 | import { formatRegions, formatRegion } from '../../formater'; 3 | import lowercasekeys from 'lowercase-keys'; 4 | 5 | export const Region = { 6 | region: async (parent, { id }, context) => { 7 | const result = await context.client.query( 8 | q.Get(q.Ref(q.Collection('regions'), id)) 9 | ) 10 | if (!result.ref) { 11 | return {} 12 | } 13 | return { 14 | id: result.ref.value.id, 15 | geometry: { 16 | type: result.data.Geometry.Type, 17 | polygon: result.data.Geometry.Type === 'Polygon' ? lowercasekeys(result.data.Geometry) : null, 18 | multipolygon: result.data.Geometry.Type === 'MultiPolygon' ? lowercasekeys(result.data.Geometry) : null 19 | }, 20 | name: result.data.Name, 21 | province: result.data.Province, 22 | code: result.data.Code, 23 | }; 24 | }, 25 | regions: async (parent, { after, size }, context) => { 26 | const pagination = after && size ? { after: [ q.Ref(q.Collection('regions'), after) ], size } : { size: 100 }; 27 | const result = await context.client.query( 28 | q.Paginate( 29 | q.Match(q.Index('regions_sort_by_ref')), 30 | pagination 31 | ) 32 | ); 33 | return { data: formatRegions(result.data), after: formatRegion(result.after) }; 34 | }, 35 | countRegions: async (parent, args, context) => { 36 | const { data } = await context.client.query( 37 | q.Paginate( 38 | q.Match(q.Index('all_regions')), 39 | { size: 50000 }, 40 | ) 41 | ); 42 | return data.length; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/lambda/schema/fokontany.js: -------------------------------------------------------------------------------- 1 | import { objectType, extendType, intArg, idArg } from 'nexus'; 2 | import { Geometry } from './geometry'; 3 | import resolvers from '../resolvers'; 4 | 5 | export const Fokontany = objectType({ 6 | name: 'Fokontany', 7 | definition(t) { 8 | t.id('id', { nullable: true }) 9 | t.string('name', { nullable: true }) 10 | t.string('province', { nullable: true }) 11 | t.string('code', { nullable: true }) 12 | t.string('commune', { nullable: true }) 13 | t.string('district', { nullable: true }) 14 | t.string('region', { nullable: true }) 15 | t.field('geometry', { type: Geometry, nullable: true }) 16 | } 17 | }) 18 | 19 | export const FokontanyList = objectType({ 20 | name: 'FokontanyList', 21 | definition(t) { 22 | t.field('data', { list: [false], type: Fokontany, nullable: true }) 23 | t.field('after', { type: Fokontany, nullable: true }) 24 | } 25 | }) 26 | 27 | export const FokontanyQuery = extendType({ 28 | type: 'Query', 29 | definition(t) { 30 | t.field('fokontany', { 31 | args: { 32 | id: idArg({ required: true }) 33 | }, 34 | type: Fokontany, 35 | nullable: true, 36 | resolve: resolvers.Query.fokontany, 37 | }) 38 | t.field('allFokontany', { 39 | args: { 40 | after: idArg({ required: false }), 41 | size: intArg({ required: false }) 42 | }, 43 | type: FokontanyList, 44 | nullable: true , 45 | resolve: resolvers.Query.allFokontany, 46 | }) 47 | t.int('countFokontany', { 48 | nullable: true, 49 | resolve: resolvers.Query.countFokontany 50 | }) 51 | } 52 | }) 53 | -------------------------------------------------------------------------------- /src/lambda/resolvers/Query/district.js: -------------------------------------------------------------------------------- 1 | import { query as q } from 'faunadb'; 2 | import { formatDistricts, formatDistrict } from '../../formater'; 3 | import lowercasekeys from 'lowercase-keys'; 4 | 5 | export const District = { 6 | district: async (parent, { id }, context) => { 7 | const result = await context.client.query( 8 | q.Get(q.Ref(q.Collection('districts'), id)) 9 | ) 10 | if (!result.ref) { 11 | return {} 12 | } 13 | return { 14 | id: result.ref.value.id, 15 | geometry: { 16 | type: result.data.Geometry.Type, 17 | polygon: result.data.Geometry.Type === 'Polygon' ? lowercasekeys(result.data.Geometry) : null, 18 | multipolygon: result.data.Geometry.Type === 'MultiPolygon' ? lowercasekeys(result.data.Geometry) : null 19 | }, 20 | name: result.data.Name, 21 | province: result.data.Province, 22 | code: result.data.Code, 23 | region: result.data.Region, 24 | }; 25 | }, 26 | districts: async (parent, { after, size }, context) => { 27 | const pagination = after && size ? { after: [ q.Ref(q.Collection('districts'), after) ], size } : { size: 10 }; 28 | const result = await context.client.query( 29 | q.Paginate( 30 | q.Match(q.Index('districts_sort_by_ref')), 31 | pagination 32 | ) 33 | ); 34 | return { data: formatDistricts(result.data), after: formatDistrict(result.after) }; 35 | }, 36 | countDistricts: async (parent, args, context) => { 37 | const { data } = await context.client.query( 38 | q.Paginate( 39 | q.Match(q.Index('all_districts')), 40 | { size: 50000 }, 41 | ) 42 | ); 43 | return data.length; 44 | } 45 | } -------------------------------------------------------------------------------- /src/lambda/resolvers/Query/commune.js: -------------------------------------------------------------------------------- 1 | 2 | import { query as q } from 'faunadb'; 3 | import { formatCommunes, formatCommune } from '../../formater'; 4 | import lowercasekeys from 'lowercase-keys'; 5 | 6 | export const Commune = { 7 | commune: async (parent, { id }, context) => { 8 | const result = await context.client.query( 9 | q.Get(q.Ref(q.Collection('communes'), id)) 10 | ) 11 | if (!result.ref) { 12 | return {} 13 | } 14 | return { 15 | id: result.ref.value.id, 16 | geometry: { 17 | type: result.data.Geometry.Type, 18 | polygon: result.data.Geometry.Type === 'Polygon' ? lowercasekeys(result.data.Geometry) : null, 19 | multipolygon: result.data.Geometry.Type === 'MultiPolygon' ? lowercasekeys(result.data.Geometry) : null 20 | }, 21 | name: result.data.Name, 22 | province: result.data.Province, 23 | code: result.data.Code, 24 | district: result.data.District, 25 | region: result.data.Region, 26 | }; 27 | }, 28 | communes: async (parent, { after, size }, context) => { 29 | const pagination = after && size ? { after: [ q.Ref(q.Collection('communes'), after) ], size } : { size: 100 }; 30 | const result = await context.client.query( 31 | q.Paginate( 32 | q.Match(q.Index('communes_sort_by_ref')), 33 | pagination 34 | ) 35 | ); 36 | return { data: formatCommunes(result.data), after: formatCommune(result.after) }; 37 | }, 38 | countCommunes: async (parent, args, context) => { 39 | const { data } = await context.client.query( 40 | q.Paginate( 41 | q.Match(q.Index('all_communes')), 42 | { size: 50000 }, 43 | ) 44 | ); 45 | return data.length; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/lambda/resolvers/Query/fokontany.js: -------------------------------------------------------------------------------- 1 | import { query as q } from 'faunadb'; 2 | import { formatFokontany, formatFokontanyItem } from '../../formater'; 3 | import lowercasekeys from 'lowercase-keys'; 4 | 5 | export const Fokontany = { 6 | fokontany: async (parent, { id }, context) => { 7 | const result = await context.client.query( 8 | q.Get(q.Ref(q.Collection('fokontany'), id)) 9 | ) 10 | if (!result.ref) { 11 | return {} 12 | } 13 | return { 14 | id: result.ref.value.id, 15 | geometry: { 16 | type: result.data.Geometry.Type, 17 | polygon: result.data.Geometry.Type === 'Polygon' ? lowercasekeys(result.data.Geometry) : null, 18 | multipolygon: result.data.Geometry.Type === 'MultiPolygon' ? lowercasekeys(result.data.Geometry) : null 19 | }, 20 | name: result.data.Name, 21 | province: result.data.Province, 22 | code: result.data.Code, 23 | commune: result.data.Commune, 24 | district: result.data.District, 25 | region: result.data.Region, 26 | }; 27 | }, 28 | allFokontany: async (parent, { after, size }, context) => { 29 | const pagination = after && size ? { after: [ q.Ref(q.Collection('fokontany'), after) ], size } : { size: 100 }; 30 | const result = await context.client.query( 31 | q.Paginate( 32 | q.Match(q.Index('fokontany_sort_by_ref')), 33 | pagination 34 | ), 35 | ); 36 | return { data: formatFokontany(result.data), after: formatFokontanyItem(result.after) }; 37 | }, 38 | countFokontany: async (parent, args, context) => { 39 | const { data } = await context.client.query( 40 | q.Paginate( 41 | q.Match(q.Index('all_fokontany')), 42 | { size: 50000 }, 43 | ) 44 | ); 45 | return data.length; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 14 | 23 | MG Explorer 24 | 25 | 26 | 29 |
30 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "madagascar-explorer", 3 | "version": "0.5.0", 4 | "private": true, 5 | "dependencies": { 6 | "@apollo/react-hooks": "^3.1.3", 7 | "@deck.gl/core": "^8.1.0", 8 | "@deck.gl/layers": "^8.1.0", 9 | "@deck.gl/mapbox": "^8.1.0", 10 | "@deck.gl/react": "^8.1.0", 11 | "alasql": "^0.5.5", 12 | "antd": "^3.26.14", 13 | "apollo-boost": "^0.4.7", 14 | "apollo-server-lambda": "^2.14.2", 15 | "axios": "^0.19.0", 16 | "bufferutil": "^4.0.1", 17 | "encoding": "^0.1.12", 18 | "faunadb": "^2.13.0", 19 | "graphql": "^14.6.0", 20 | "lowercase-keys": "^2.0.0", 21 | "nexus": "^0.12.0-rc.13", 22 | "react": "^16.8.6", 23 | "react-dom": "^16.8.6", 24 | "react-infinite-scroll-hook": "^2.0.1", 25 | "react-map-gl": "^5.2.3", 26 | "react-md-spinner": "^1.0.0", 27 | "react-router-dom": "^5.1.2", 28 | "react-scripts": "^3.0.1", 29 | "styled-components": "^5.0.1", 30 | "utf-8-validate": "^5.0.2" 31 | }, 32 | "scripts": { 33 | "start": "react-scripts start", 34 | "dev": "dotenv -e .env react-scripts start", 35 | "start:lambda": "NODE_ENV=development netlify-lambda serve src/lambda", 36 | "dev:lambda": "dotenv -e .env npm run start:lambda", 37 | "build": "run-p build:**", 38 | "build:app": "react-scripts build", 39 | "build:lambda": "netlify-lambda build src/lambda", 40 | "test": "react-scripts test", 41 | "eject": "react-scripts eject", 42 | "lint": "standard --verbose | snazzy", 43 | "fixcode": "standard --fix" 44 | }, 45 | "eslintConfig": { 46 | "extends": "react-app" 47 | }, 48 | "browserslist": [ 49 | ">0.2%", 50 | "not dead", 51 | "not ie <= 11", 52 | "not op_mini all" 53 | ], 54 | "devDependencies": { 55 | "dotenv-cli": "^3.1.0", 56 | "husky": "^4.2.3", 57 | "netlify-lambda": "^1.4.5", 58 | "npm-run-all": "^4.1.5", 59 | "prettier": "^1.19.1", 60 | "snazzy": "^8.0.0", 61 | "standard": "^14.3.3" 62 | }, 63 | "standard": { 64 | "ignore": [ 65 | "src/lambda", 66 | "src/serviceWorker.js", 67 | "src/*test.js" 68 | ] 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/components/Popover/PopoverList.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { withRouter } from 'react-router-dom' 3 | 4 | class PopoverList extends Component { 5 | componentDidMount () { 6 | this.refs.iScroll.addEventListener('scroll', () => { 7 | if (this.refs.iScroll.scrollTop + this.refs.iScroll.clientHeight >= this.refs.iScroll.scrollHeight) { 8 | this.props.handleUpdate() 9 | } 10 | }) 11 | } 12 | 13 | render () { 14 | const { 15 | keyword, 16 | filter, 17 | loading, 18 | error, 19 | data, 20 | history 21 | } = this.props 22 | const fokontany = keyword === '' ? this.props.fokontany : data.search.fokontany 23 | const communes = keyword === '' ? this.props.communes : data.search.communes 24 | const districts = keyword === '' ? this.props.districts : data.search.districts 25 | const regions = keyword === '' ? this.props.regions : data.search.regions 26 | return ( 27 |
28 | { 29 | filter === 1 && !loading && !error && ( 30 | 41 | ) 42 | } 43 | { 44 | filter === 2 && !loading && !error && ( 45 | 59 | ) 60 | } 61 | { 62 | filter === 3 && !loading && !error && ( 63 | 77 | ) 78 | } 79 | { 80 | filter === 4 && !loading && !error && ( 81 | 95 | ) 96 | } 97 |
98 | 99 | ) 100 | } 101 | } 102 | 103 | export default withRouter(PopoverList) 104 | -------------------------------------------------------------------------------- /src/components/Commune/index.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | import DeckGL from '@deck.gl/react' 3 | import { GeoJsonLayer } from '@deck.gl/layers' 4 | import MapGL from 'react-map-gl' 5 | import Popover from '../Popover' 6 | import { useQuery } from '@apollo/react-hooks' 7 | import { gql } from 'apollo-boost' 8 | import MDSpinner from 'react-md-spinner' 9 | 10 | const COMMUNE = gql` 11 | query Commune($id: ID!) { 12 | commune(id:$id) { 13 | id 14 | name 15 | code 16 | province 17 | geometry { 18 | type 19 | polygon { 20 | type 21 | coordinates 22 | } 23 | multipolygon { 24 | type 25 | coordinates 26 | } 27 | } 28 | } 29 | } 30 | ` 31 | 32 | // Set your mapbox access token here 33 | const MAPBOX_ACCESS_TOKEN = process.env.REACT_APP_MAPBOX_ACCESS_TOKEN 34 | const MAPBOX_STYLE = process.env.REACT_APP_MAP_STYLE 35 | 36 | const Commune = (props) => { 37 | const { id } = props.match.params 38 | const { loading, error, data } = useQuery(COMMUNE, { variables: { id } }) 39 | const [showPopup, setShowPopup] = useState(false) 40 | const [name, setName] = useState('') 41 | const [popupX, setPopupX] = useState(0) 42 | const [popupY, setPopupY] = useState(0) 43 | const [layers, setLayers] = useState([]) 44 | const [expanded, setExpanded] = useState(false) 45 | const popoverClass = `popover ${expanded ? 'expand' : 'shrink'}` 46 | const [viewport, setViewport] = useState({ 47 | longitude: 47.52186, 48 | latitude: -18.91449, 49 | zoom: 11.97, 50 | bearing: 0, 51 | pitch: 30 52 | }) 53 | 54 | useEffect(() => { 55 | setShowPopup(false) 56 | setLayers([]) 57 | }, [id]) 58 | 59 | useEffect(() => { 60 | if (!loading && !error) { 61 | const { geometry, name } = data.commune 62 | const { type } = geometry 63 | const [longitude, latitude] = type === 'Polygon' ? geometry.polygon.coordinates[0][0] : geometry.multipolygon.coordinates[0][0][0] 64 | const location = { 65 | ...viewport, 66 | longitude, 67 | latitude, 68 | zoom: 9 69 | } 70 | setName(name) 71 | setViewport(location) 72 | const geojson = { 73 | type: 'FeatureCollection', 74 | features: [ 75 | { 76 | type: 'Feature', 77 | geometry: type === 'Polygon' ? geometry.polygon : geometry.multipolygon 78 | } 79 | ] 80 | } 81 | setLayers([ 82 | new GeoJsonLayer({ 83 | id: 'geojson-layer', 84 | data: geojson, 85 | pickable: true, 86 | stroked: false, 87 | filled: true, 88 | extruded: false, 89 | lineWidthScale: 20, 90 | lineWidthMinPixels: 2, 91 | getElevation: 1, 92 | getFillColor: [250, 84, 28, 127], 93 | onHover: ({ x, y }) => { 94 | if (x > 0 && y > 0) { 95 | setPopupX(x - 40) 96 | setPopupY(y - 40) 97 | setShowPopup(true) 98 | } 99 | } 100 | }) 101 | ]) 102 | } 103 | }, [loading, error, data]) 104 | 105 | if (loading) { 106 | return ( 107 |
108 | 109 |
110 | ) 111 | } 112 | 113 | return ( 114 |
115 | { 116 | showPopup && ( 117 |
119 |
120 |
121 |
122 |
123 |
124 |
125 |
{name}
126 |
127 |
128 |
129 |
130 |
131 | ) 132 | } 133 | setExpanded(false)} 138 | > 139 | setViewport(value)} 147 | /> 148 | 149 | 150 |
151 | ) 152 | } 153 | 154 | export default Commune 155 | -------------------------------------------------------------------------------- /src/components/Region/index.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | import DeckGL from '@deck.gl/react' 3 | import { GeoJsonLayer } from '@deck.gl/layers' 4 | import MapGL from 'react-map-gl' 5 | import Popover from '../Popover' 6 | import { useQuery } from '@apollo/react-hooks' 7 | import { gql } from 'apollo-boost' 8 | import MDSpinner from 'react-md-spinner' 9 | 10 | const REGION = gql` 11 | query Region($id: ID!) { 12 | region(id: $id) { 13 | id 14 | name 15 | code 16 | province 17 | geometry { 18 | type 19 | polygon { 20 | type 21 | coordinates 22 | } 23 | multipolygon { 24 | type 25 | coordinates 26 | } 27 | } 28 | } 29 | } 30 | ` 31 | 32 | // Set your mapbox access token here 33 | const MAPBOX_ACCESS_TOKEN = process.env.REACT_APP_MAPBOX_ACCESS_TOKEN 34 | const MAPBOX_STYLE = process.env.REACT_APP_MAP_STYLE 35 | 36 | const Region = (props) => { 37 | const { id } = props.match.params 38 | const { loading, error, data } = useQuery(REGION, { variables: { id } }) 39 | const [showPopup, setShowPopup] = useState(false) 40 | const [name, setName] = useState('') 41 | const [popupX, setPopupX] = useState(0) 42 | const [popupY, setPopupY] = useState(0) 43 | const [layers, setLayers] = useState([]) 44 | const [expanded, setExpanded] = useState(false) 45 | const popoverClass = `popover ${expanded ? 'expand' : 'shrink'}` 46 | const [viewport, setViewport] = useState({ 47 | longitude: 47.52186, 48 | latitude: -18.91449, 49 | zoom: 11.97, 50 | bearing: 0, 51 | pitch: 30 52 | }) 53 | 54 | useEffect(() => { 55 | setShowPopup(false) 56 | setLayers([]) 57 | }, [id]) 58 | 59 | useEffect(() => { 60 | if (!loading && !error) { 61 | const { geometry, name } = data.region 62 | const { type } = geometry 63 | const [longitude, latitude] = type === 'Polygon' ? geometry.polygon.coordinates[0][0] : geometry.multipolygon.coordinates[0][0][0] 64 | const location = { 65 | ...viewport, 66 | longitude, 67 | latitude, 68 | zoom: 6 69 | } 70 | setName(name) 71 | setViewport(location) 72 | const geojson = { 73 | type: 'FeatureCollection', 74 | features: [ 75 | { 76 | type: 'Feature', 77 | geometry: type === 'Polygon' ? geometry.polygon : geometry.multipolygon 78 | } 79 | ] 80 | } 81 | setLayers([ 82 | new GeoJsonLayer({ 83 | id: 'geojson-layer', 84 | data: geojson, 85 | pickable: true, 86 | stroked: false, 87 | filled: true, 88 | extruded: false, 89 | lineWidthScale: 20, 90 | lineWidthMinPixels: 2, 91 | getElevation: 1, 92 | getFillColor: [82, 196, 26, 127], 93 | onHover: ({ x, y }) => { 94 | if (x > 0 && y > 0) { 95 | setPopupX(x - 40) 96 | setPopupY(y - 40) 97 | setShowPopup(true) 98 | } 99 | } 100 | }) 101 | ]) 102 | } 103 | }, [loading, error, data]) 104 | 105 | if (loading) { 106 | return ( 107 |
108 | 109 |
110 | ) 111 | } 112 | 113 | return ( 114 |
115 | { 116 | showPopup && ( 117 |
119 |
120 |
121 |
122 |
123 |
124 |
125 |
{name}
126 |
127 |
128 |
129 |
130 |
131 | ) 132 | } 133 | setExpanded(false)} 138 | > 139 | setViewport(value)} 147 | > 148 | 149 | 150 | 151 |
152 | ) 153 | } 154 | 155 | export default Region 156 | -------------------------------------------------------------------------------- /src/components/District/index.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | import DeckGL from '@deck.gl/react' 3 | import { GeoJsonLayer } from '@deck.gl/layers' 4 | import MapGL from 'react-map-gl' 5 | import Popover from '../Popover' 6 | import { useQuery } from '@apollo/react-hooks' 7 | import { gql } from 'apollo-boost' 8 | import MDSpinner from 'react-md-spinner' 9 | 10 | const DISTRICT = gql` 11 | query District($id: ID!) { 12 | district(id: $id) { 13 | id 14 | name 15 | code 16 | province 17 | region 18 | geometry { 19 | type 20 | polygon { 21 | type 22 | coordinates 23 | } 24 | multipolygon { 25 | type 26 | coordinates 27 | } 28 | } 29 | } 30 | } 31 | ` 32 | 33 | // Set your mapbox access token here 34 | const MAPBOX_ACCESS_TOKEN = process.env.REACT_APP_MAPBOX_ACCESS_TOKEN 35 | const MAPBOX_STYLE = process.env.REACT_APP_MAP_STYLE 36 | 37 | const District = (props) => { 38 | const { id } = props.match.params 39 | const { loading, error, data } = useQuery(DISTRICT, { variables: { id } }) 40 | const [showPopup, setShowPopup] = useState(false) 41 | const [name, setName] = useState('') 42 | const [popupX, setPopupX] = useState(0) 43 | const [popupY, setPopupY] = useState(0) 44 | const [layers, setLayers] = useState([]) 45 | const [expanded, setExpanded] = useState(false) 46 | const popoverClass = `popover ${expanded ? 'expand' : 'shrink'}` 47 | const [viewport, setViewport] = useState({ 48 | longitude: 47.52186, 49 | latitude: -18.91449, 50 | zoom: 11.97, 51 | bearing: 0, 52 | pitch: 30 53 | }) 54 | 55 | useEffect(() => { 56 | setShowPopup(false) 57 | setLayers([]) 58 | }, [id]) 59 | 60 | useEffect(() => { 61 | if (!loading && !error) { 62 | const { geometry, name } = data.district 63 | const { type } = geometry 64 | const [longitude, latitude] = type === 'Polygon' ? geometry.polygon.coordinates[0][0] : geometry.multipolygon.coordinates[0][0][0] 65 | const location = { 66 | ...viewport, 67 | longitude, 68 | latitude, 69 | zoom: 8 70 | } 71 | setName(name) 72 | setViewport(location) 73 | const geojson = { 74 | type: 'FeatureCollection', 75 | features: [ 76 | { 77 | type: 'Feature', 78 | geometry: type === 'Polygon' ? geometry.polygon : geometry.multipolygon 79 | } 80 | ] 81 | } 82 | setLayers([ 83 | new GeoJsonLayer({ 84 | id: 'geojson-layer', 85 | data: geojson, 86 | pickable: true, 87 | stroked: false, 88 | filled: true, 89 | extruded: false, 90 | lineWidthScale: 20, 91 | lineWidthMinPixels: 2, 92 | getElevation: 1, 93 | getFillColor: [47, 84, 235, 127], 94 | onHover: ({ x, y }) => { 95 | if (x > 0 && y > 0) { 96 | setPopupX(x - 40) 97 | setPopupY(y - 40) 98 | setShowPopup(true) 99 | } 100 | } 101 | }) 102 | ]) 103 | } 104 | }, [loading, error, data]) 105 | 106 | if (loading) { 107 | return ( 108 |
109 | 110 |
111 | ) 112 | } 113 | 114 | return ( 115 |
116 | { 117 | showPopup && ( 118 |
120 |
121 |
122 |
123 |
124 |
125 |
126 |
{name}
127 |
128 |
129 |
130 |
131 |
132 | ) 133 | } 134 | setExpanded(false)} 139 | > 140 | setViewport(value)} 148 | /> 149 | 150 | 151 |
152 | ) 153 | } 154 | 155 | export default District 156 | -------------------------------------------------------------------------------- /src/components/Fokontany/index.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | import DeckGL from '@deck.gl/react' 3 | import { GeoJsonLayer } from '@deck.gl/layers' 4 | import MapGL from 'react-map-gl' 5 | import Popover from '../Popover' 6 | import { useQuery } from '@apollo/react-hooks' 7 | import { gql } from 'apollo-boost' 8 | import MDSpinner from 'react-md-spinner' 9 | 10 | const FOKONTANY = gql` 11 | query Fokontany($id: ID!) { 12 | fokontany(id: $id) { 13 | id 14 | name 15 | code 16 | commune 17 | province 18 | district 19 | region 20 | geometry { 21 | type 22 | polygon { 23 | type 24 | coordinates 25 | } 26 | multipolygon { 27 | type 28 | coordinates 29 | } 30 | } 31 | } 32 | } 33 | ` 34 | 35 | // Set your mapbox access token here 36 | const MAPBOX_ACCESS_TOKEN = process.env.REACT_APP_MAPBOX_ACCESS_TOKEN 37 | const MAPBOX_STYLE = process.env.REACT_APP_MAP_STYLE 38 | 39 | const Fokontany = (props) => { 40 | const { id } = props.match.params 41 | const { loading, error, data } = useQuery(FOKONTANY, { variables: { id } }) 42 | const [showPopup, setShowPopup] = useState(false) 43 | const [name, setName] = useState('') 44 | const [popupX, setPopupX] = useState(0) 45 | const [popupY, setPopupY] = useState(0) 46 | const [layers, setLayers] = useState([]) 47 | const [expanded, setExpanded] = useState(false) 48 | const popoverClass = `popover ${expanded ? 'expand' : 'shrink'}` 49 | const [viewport, setViewport] = useState({ 50 | longitude: 47.52186, 51 | latitude: -18.91449, 52 | zoom: 11.97, 53 | bearing: 0, 54 | pitch: 30 55 | }) 56 | 57 | useEffect(() => { 58 | setShowPopup(false) 59 | setLayers([]) 60 | }, [id]) 61 | 62 | useEffect(() => { 63 | if (!loading && !error) { 64 | const { geometry, name } = data.fokontany 65 | const { type } = geometry 66 | const [longitude, latitude] = type === 'Polygon' ? geometry.polygon.coordinates[0][0] : geometry.multipolygon.coordinates[0][0][0] 67 | const location = { 68 | ...viewport, 69 | longitude, 70 | latitude, 71 | zoom: 13 72 | } 73 | setName(name) 74 | setViewport(location) 75 | const geojson = { 76 | type: 'FeatureCollection', 77 | features: [ 78 | { 79 | type: 'Feature', 80 | geometry: type === 'Polygon' ? geometry.polygon : geometry.multipolygon 81 | } 82 | ] 83 | } 84 | setLayers([ 85 | new GeoJsonLayer({ 86 | id: 'geojson-layer', 87 | data: geojson, 88 | pickable: true, 89 | stroked: false, 90 | filled: true, 91 | extruded: false, 92 | lineWidthScale: 20, 93 | lineWidthMinPixels: 2, 94 | getElevation: 1, 95 | getFillColor: [235, 47, 150, 127], 96 | onHover: ({ x, y }) => { 97 | if (x > 0 && y > 0) { 98 | setPopupX(x - 40) 99 | setPopupY(y - 40) 100 | setShowPopup(true) 101 | } 102 | } 103 | }) 104 | ]) 105 | } 106 | }, [loading, error, data]) 107 | 108 | if (loading) { 109 | return ( 110 |
111 | 112 |
113 | ) 114 | } 115 | 116 | return ( 117 |
118 | { 119 | showPopup && ( 120 |
122 |
123 |
124 |
125 |
126 |
127 |
128 |
{name}
129 |
130 |
131 |
132 |
133 |
134 | ) 135 | } 136 | setExpanded(false)} 141 | > 142 | setViewport(value)} 150 | /> 151 | 152 | 153 |
154 | ) 155 | } 156 | 157 | export default Fokontany 158 | -------------------------------------------------------------------------------- /src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | // In production, we register a service worker to serve assets from local cache. 2 | 3 | // This lets the app load faster on subsequent visits in production, and gives 4 | // it offline capabilities. However, it also means that developers (and users) 5 | // will only see deployed updates on the "N+1" visit to a page, since previously 6 | // cached resources are updated in the background. 7 | 8 | // To learn more about the benefits of this model, read https://goo.gl/KwvDNy. 9 | // This link also includes instructions on opting out of this behavior. 10 | 11 | const isLocalhost = Boolean( 12 | window.location.hostname === 'localhost' || 13 | // [::1] is the IPv6 localhost address. 14 | window.location.hostname === '[::1]' || 15 | // 127.0.0.1/8 is considered localhost for IPv4. 16 | window.location.hostname.match( 17 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 18 | ) 19 | ); 20 | 21 | export function register(config) { 22 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 23 | // The URL constructor is available in all browsers that support SW. 24 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location); 25 | if (publicUrl.origin !== window.location.origin) { 26 | // Our service worker won't work if PUBLIC_URL is on a different origin 27 | // from what our page is served on. This might happen if a CDN is used to 28 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 29 | return; 30 | } 31 | 32 | window.addEventListener('load', () => { 33 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 34 | 35 | if (isLocalhost) { 36 | // This is running on localhost. Let's check if a service worker still exists or not. 37 | checkValidServiceWorker(swUrl, config); 38 | 39 | // Add some additional logging to localhost, pointing developers to the 40 | // service worker/PWA documentation. 41 | navigator.serviceWorker.ready.then(() => { 42 | console.log( 43 | 'This web app is being served cache-first by a service ' + 44 | 'worker. To learn more, visit https://goo.gl/SC7cgQ' 45 | ); 46 | }); 47 | } else { 48 | // Is not local host. Just register service worker 49 | registerValidSW(swUrl, config); 50 | } 51 | }); 52 | } 53 | } 54 | 55 | function registerValidSW(swUrl, config) { 56 | navigator.serviceWorker 57 | .register(swUrl) 58 | .then(registration => { 59 | registration.onupdatefound = () => { 60 | const installingWorker = registration.installing; 61 | installingWorker.onstatechange = () => { 62 | if (installingWorker.state === 'installed') { 63 | if (navigator.serviceWorker.controller) { 64 | // At this point, the old content will have been purged and 65 | // the fresh content will have been added to the cache. 66 | // It's the perfect time to display a "New content is 67 | // available; please refresh." message in your web app. 68 | console.log('New content is available; please refresh.'); 69 | 70 | // Execute callback 71 | if (config && config.onUpdate) { 72 | config.onUpdate(registration); 73 | } 74 | } else { 75 | // At this point, everything has been precached. 76 | // It's the perfect time to display a 77 | // "Content is cached for offline use." message. 78 | console.log('Content is cached for offline use.'); 79 | 80 | // Execute callback 81 | if (config && config.onSuccess) { 82 | config.onSuccess(registration); 83 | } 84 | } 85 | } 86 | }; 87 | }; 88 | }) 89 | .catch(error => { 90 | console.error('Error during service worker registration:', error); 91 | }); 92 | } 93 | 94 | function checkValidServiceWorker(swUrl, config) { 95 | // Check if the service worker can be found. If it can't reload the page. 96 | fetch(swUrl) 97 | .then(response => { 98 | // Ensure service worker exists, and that we really are getting a JS file. 99 | if ( 100 | response.status === 404 || 101 | response.headers.get('content-type').indexOf('javascript') === -1 102 | ) { 103 | // No service worker found. Probably a different app. Reload the page. 104 | navigator.serviceWorker.ready.then(registration => { 105 | registration.unregister().then(() => { 106 | window.location.reload(); 107 | }); 108 | }); 109 | } else { 110 | // Service worker found. Proceed as normal. 111 | registerValidSW(swUrl, config); 112 | } 113 | }) 114 | .catch(() => { 115 | console.log( 116 | 'No internet connection found. App is running in offline mode.' 117 | ); 118 | }); 119 | } 120 | 121 | export function unregister() { 122 | if ('serviceWorker' in navigator) { 123 | navigator.serviceWorker.ready.then(registration => { 124 | registration.unregister(); 125 | }); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Madagascar Explorer 2 | 3 |

4 | 5 | 6 | 7 | 8 | 9 | 10 | GitHub code size in bytes 11 | 12 | GitHub contributors 13 | 14 | 15 | License: BSD 16 | 17 | 18 |

19 | 20 | This project is a reference demo showing you how to use [Create React App v3](https://github.com/facebookincubator/create-react-app) and [netlify-lambda v1](https://github.com/netlify/netlify-lambda) together in a [Netlify Dev](https://www.netlify.com/docs/cli/?utm_source=github&utm_medium=swyx-CRAL&utm_campaign=devex#netlify-dev-beta) workflow. You can clone this and immediately be productive with a React app with serverless Netlify Functions in the same repo. Alternatively you can deploy straight to Netlify with this one-click Deploy: 21 | 22 | 23 | [![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg?utm_source=github&utm_medium=swyx-CRAL&utm_campaign=devex)](https://app.netlify.com/start/deploy?repository=https://github.com/netlify/create-react-app-lambda&utm_source=github&utm_medium=swyx-CRAL&utm_campaign=devex) 24 | 25 | > ⚠️NOTE: You may not need this project at all. [Netlify Dev](https://github.com/netlify/netlify-dev-plugin) works with `create-react-app` out of the box! Only use `netlify-lambda` if you need a build step for your functions, eg if you want to use Babel or TypeScript ([see its README for details](https://github.com/netlify/netlify-lambda/blob/master/README.md#netlify-lambda)). 26 | 27 | [![Screenshot of Madagascar Explorer](preview.png)](https://mg-explorer.netlify.com/) 28 | [![Screenshot of Madagascar Explorer API](preview-api.png)](https://mg-explorer.netlify.com/.netlify/functions/graphql) 29 | 30 | 31 | ## Project Setup 32 | 33 | **Source**: The main addition to base Create-React-App is a new folder: `src/lambda`. This folder is specified and can be changed in the `package.json` script: `"build:lambda": "netlify-lambda build src/lambda"`. 34 | 35 | **Dist**: Each JavaScript file in there will be built for Netlify Function deployment in `/built-lambda`, specified in [`netlify.toml`](https://www.netlify.com/docs/netlify-toml-reference/?utm_source=github&utm_medium=swyx-CRAL&utm_campaign=devex). 36 | 37 | As an example, we've included a small `src/lambda/hello.js` function, which will be deployed to `/.netlify/functions/hello`. We've also included an async lambda example using async/await syntax in `async-dadjoke.js`. 38 | 39 | ## Video 40 | 41 | Learn how to set this up yourself (and why everything is the way it is) from scratch in a video: https://www.youtube.com/watch?v=3ldSM98nCHI 42 | 43 | ## Babel/webpack compilation 44 | 45 | All functions (inside `src/lambda`) are compiled with webpack using Babel, so you can use modern JavaScript, import npm modules, etc., without any extra setup. 46 | 47 | ## Local Development 48 | 49 | ```bash 50 | ## prep steps for first time users 51 | npm i -g netlify-cli # Make sure you have the [Netlify CLI](https://github.com/netlify/cli) installed 52 | git clone https://github.com/netlify/create-react-app-lambda ## clone this repo 53 | cd create-react-app-lambda ## change into this repo 54 | yarn # install all dependencies 55 | 56 | ## done every time you start up this project 57 | ntl dev ## nice shortcut for `netlify dev`, starts up create-react-app AND a local Node.js server for your Netlify functions 58 | ``` 59 | 60 | This fires up [Netlify Dev](https://www.netlify.com/docs/cli/?utm_source=github&utm_medium=swyx-CRAL&utm_campaign=devex#netlify-dev-beta), which: 61 | 62 | - Detects that you are running a `create-react-app` project and runs the npm script that contains `react-scripts start`, which in this project is the `start` script 63 | - Detects that you use `netlify-lambda` as a [function builder](https://github.com/netlify/netlify-dev-plugin/#function-builders-function-builder-detection-and-relationship-with-netlify-lambda), and runs the npm script that contains `netlify-lambda build`, which in this project is the `build:lambda` script. 64 | 65 | You can view the project locally via Netlify Dev, via `localhost:8888`. 66 | 67 | Each function will be available at the same port as well: 68 | 69 | - `http://localhost:8888/.netlify/functions/hello` and 70 | - `http://localhost:8888/.netlify/functions/async-dadjoke` 71 | 72 | ## Deployment 73 | 74 | During deployment, this project is configured, inside `netlify.toml` to run the build `command`: `yarn build`. 75 | 76 | `yarn build` corresponds to the npm script `build`, which uses `npm-run-all` (aka `run-p`) to concurrently run `"build:app"` (aka `react-scripts build`) and `build:lambda` (aka `netlify-lambda build src/lambda`). 77 | 78 | ## Typescript 79 | 80 |
81 | 82 | Click for instructions 83 | 84 | 85 | You can use Typescript in both your frontend React code (with `react-scripts` v2.1+) and your serverless functions (with `netlify-lambda` v1.1+). Follow these instructions: 86 | 87 | 1. `yarn add -D typescript @types/node @types/react @types/react-dom @babel/preset-typescript @types/aws-lambda` 88 | 2. convert `src/lambda/hello.js` to `src/lambda/hello.ts` 89 | 3. use types in your event handler: 90 | 91 | ```ts 92 | import { Handler, Context, Callback, APIGatewayEvent } from 'aws-lambda' 93 | 94 | interface HelloResponse { 95 | statusCode: number 96 | body: string 97 | } 98 | 99 | const handler: Handler = (event: APIGatewayEvent, context: Context, callback: Callback) => { 100 | const params = event.queryStringParameters 101 | const response: HelloResponse = { 102 | statusCode: 200, 103 | body: JSON.stringify({ 104 | msg: `Hello world ${Math.floor(Math.random() * 10)}`, 105 | params, 106 | }), 107 | } 108 | 109 | callback(undefined, response) 110 | } 111 | 112 | export { handler } 113 | ``` 114 | 115 | rerun and see it work! 116 | 117 | You are free to set up your `tsconfig.json` and `tslint` as you see fit. 118 | 119 |
120 | 121 | **If you want to try working in Typescript on the client and lambda side**: There are a bunch of small setup details to get right. Check https://github.com/sw-yx/create-react-app-lambda-typescript for a working starter. 122 | 123 | ## Routing and authentication with Netlify Identity 124 | 125 | For a full demo of routing and authentication, check this branch: https://github.com/netlify/create-react-app-lambda/pull/18 This example will not be maintained but may be helpful. 126 | 127 | ## Service Worker 128 | 129 | `create-react-app`'s default service worker (in `src/index.js`) does not work with lambda functions out of the box. It prevents calling the function and returns the app itself instead ([Read more](https://github.com/facebook/create-react-app/issues/2237#issuecomment-302693219)). To solve this you have to eject and enhance the service worker configuration in the webpack config. Whitelist the path of your lambda function and you are good to go. 130 | -------------------------------------------------------------------------------- /src/components/Popover/index.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | import { useQuery } from '@apollo/react-hooks' 3 | import { gql } from 'apollo-boost' 4 | import { Tag } from 'antd' 5 | import MDSpinner from 'react-md-spinner' 6 | import PopoverList from './PopoverList' 7 | 8 | const ALL_FOKONTANY = gql` 9 | query AllFokontany($after: ID, $size: Int) { 10 | allFokontany(after: $after, size: $size) { 11 | data { 12 | id 13 | name 14 | code 15 | province 16 | commune 17 | district 18 | region 19 | } 20 | after { 21 | id 22 | } 23 | } 24 | } 25 | ` 26 | 27 | const COMMUNES = gql` 28 | query Communes($after: ID, $size: Int) { 29 | communes(after: $after, size: $size) { 30 | data { 31 | id 32 | name 33 | province 34 | code 35 | district 36 | region 37 | } 38 | after { 39 | id 40 | } 41 | } 42 | } 43 | ` 44 | 45 | const DISTRICTS = gql` 46 | query Districts($after: ID, $size: Int) { 47 | districts(after: $after, size: $size) { 48 | data { 49 | id 50 | name 51 | code 52 | region 53 | } 54 | after { 55 | id 56 | } 57 | } 58 | } 59 | ` 60 | 61 | const REGIONS = gql` 62 | query Regions($after: ID, $size: Int) { 63 | regions(after: $after, size: $size) { 64 | data { 65 | id 66 | name 67 | code 68 | province 69 | } 70 | after { 71 | id 72 | } 73 | } 74 | } 75 | ` 76 | 77 | const SEARCH = gql` 78 | query Search($keyword: String!) { 79 | search(keyword: $keyword) { 80 | regions { 81 | id 82 | name 83 | code 84 | } 85 | fokontany { 86 | id 87 | name 88 | commune 89 | district 90 | region 91 | code 92 | } 93 | districts { 94 | id 95 | name 96 | region 97 | code 98 | } 99 | communes { 100 | id 101 | name 102 | district 103 | region 104 | code 105 | } 106 | } 107 | countRegions 108 | countDistricts 109 | countCommunes 110 | countFokontany 111 | } 112 | ` 113 | 114 | /* 115 | const COUNT = gql` 116 | query { 117 | countRegions 118 | countDistricts 119 | countCommunes 120 | countFokontany 121 | } 122 | ` 123 | */ 124 | 125 | const Popover = (props) => { 126 | const [filter, setFilter] = useState(1) 127 | const [keyword, setKeyword] = useState('') 128 | const [fokontany, setFokontany] = useState([]) 129 | const [communes, setCommunes] = useState([]) 130 | const [districts, setDistricts] = useState([]) 131 | const [regions, setRegions] = useState([]) 132 | const [fokontanyAfter, setFokontanyAfter] = useState(null) 133 | const [communeAfter, setCommuneAfter] = useState(null) 134 | const [districtAfter, setDistrictAfter] = useState(null) 135 | const { loading, error, data } = useQuery(SEARCH, { variables: { keyword } }) 136 | // const countRes = useQuery(COUNT) 137 | const allFokontanyRes = useQuery(ALL_FOKONTANY, { variables: { after: fokontanyAfter, size: 100 } }) 138 | const communesRes = useQuery(COMMUNES, { variables: { after: communeAfter, size: 100 } }) 139 | const districtsRes = useQuery(DISTRICTS, { variables: { after: districtAfter, size: 10 } }) 140 | const regionsRes = useQuery(REGIONS, { variables: { after: null, size: 10 } }) 141 | 142 | const handleUpdate = () => { 143 | switch (filter) { 144 | case 1: 145 | break 146 | case 2: 147 | setDistrictAfter(districts[districts.length - 1].id) 148 | break 149 | case 3: 150 | setCommuneAfter(communes[communes.length - 1].id) 151 | break 152 | case 4: 153 | setFokontanyAfter(fokontany[fokontany.length - 1].id) 154 | break 155 | default: 156 | break 157 | } 158 | } 159 | 160 | useEffect(() => { 161 | if (allFokontanyRes.data) { 162 | const newFokontany = !fokontanyAfter ? allFokontanyRes.data.allFokontany.data : [...fokontany, ...allFokontanyRes.data.allFokontany.data] 163 | setFokontany(newFokontany) 164 | } 165 | }, [allFokontanyRes.data]) 166 | 167 | useEffect(() => { 168 | if (communesRes.data) { 169 | const newCommunes = !communeAfter ? communesRes.data.communes.data : [...communes, ...communesRes.data.communes.data] 170 | setCommunes(newCommunes) 171 | } 172 | }, [communesRes.data]) 173 | 174 | useEffect(() => { 175 | if (districtsRes.data) { 176 | const newDistricts = !districtAfter ? districtsRes.data.districts.data : [...districts, ...districtsRes.data.districts.data] 177 | setDistricts(newDistricts) 178 | } 179 | }, [districtsRes.data]) 180 | 181 | useEffect(() => { 182 | if (regionsRes.data) { 183 | setRegions(regionsRes.data.regions.data) 184 | } 185 | }, [regionsRes.data]) 186 | 187 | return ( 188 |
189 |
props.setExpanded(true)}> 190 | { 196 | if (evt.target.value.length > 2 || evt.target.value.length === 0) { 197 | setKeyword(evt.target.value) 198 | } 199 | }} 200 | /> 201 | 205 | 206 | 207 | 208 | 209 | 210 |
211 | { 212 | (loading || error) && ( 213 |
214 | 215 |
216 | ) 217 | } 218 | { 219 | !loading && !error && ( 220 |
221 | setFilter(1)} 225 | > 226 | Regions ({keyword === '' ? data.countRegions : data.search.regions.length}) 227 | 228 | setFilter(2)} 232 | > 233 | Districts ({keyword === '' ? data.countDistricts : data.search.districts.length}) 234 | 235 | setFilter(3)} 239 | > 240 | Communes ({keyword === '' ? data.countCommunes : data.search.communes.length}) 241 | 242 | setFilter(4)} 246 | > 247 | Fokontany ({keyword === '' ? data.countFokontany : data.search.fokontany.length}) 248 | 249 |
250 | ) 251 | } 252 | 265 |
266 | ) 267 | } 268 | 269 | export default Popover 270 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: MarkPro; 5 | -webkit-font-smoothing: antialiased; 6 | -moz-osx-font-smoothing: grayscale; 7 | } 8 | 9 | code { 10 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 11 | monospace; 12 | } 13 | 14 | 15 | @font-face { 16 | font-family: 'MarkPro'; 17 | src: local('MarkPro'), url(./assets/fonts/MarkPro-Medium.ttf) format('truetype'); 18 | } 19 | 20 | .spinner { 21 | height: 100vh; 22 | width: 100vw; 23 | display: flex; 24 | align-items: center; 25 | justify-content: center; 26 | } 27 | 28 | 29 | #loader { 30 | height: 100%; 31 | display: flex; 32 | align-items: center; 33 | justify-content: center; 34 | } 35 | 36 | #search { 37 | border: none; 38 | font-size: 16px; 39 | width: 100%; 40 | margin-left: 50px; 41 | background-color: rgba(255,255,255,0); 42 | font-family: MarkPro; 43 | margin-right: 10px; 44 | height: 64px; 45 | } 46 | 47 | input:focus, textarea:focus, select:focus{ 48 | outline: none; 49 | } 50 | 51 | .popover { 52 | max-width: 320px; 53 | right: 16px; 54 | } 55 | 56 | .popover { 57 | position: fixed; 58 | width: 100%; 59 | background-color: rgba(255,255,255,.9); 60 | border-radius: 7px 7px 0 0; 61 | box-shadow: 0 0 5px rgba(0,0,0,.3); 62 | z-index: 2; 63 | } 64 | @supports (backdrop-filter: blur(10px)){ 65 | .popover { 66 | background-color: rgba(255,255,255,.7); 67 | backdrop-filter: blur(10px) saturate(3); 68 | } 69 | } 70 | 71 | #search-popover { 72 | top: -60px; 73 | height: 100%; 74 | bottom: 0; 75 | transform: translateY(55%); 76 | display: flex; 77 | flex-direction: column; 78 | transition: all .3s ease-in-out; 79 | } 80 | @supports (top: min(1px)) { 81 | #search-popover { 82 | top: min(-60px, calc(-60px - env(safe-area-inset-bottom) + 14px)); 83 | } 84 | } 85 | #search-popover.shrink { 86 | transform: translateY(100%); 87 | } 88 | #search-popover.shrink:not(.expand) .popover-list { 89 | opacity: .5; 90 | } 91 | #search-popover.expand { 92 | transform: translateY(80px); 93 | transform: translateY(calc(80px + env(safe-area-inset-top) + env(safe-area-inset-bottom))); 94 | } 95 | #search-popover.loading { 96 | opacity: .75; 97 | pointer-events: none; 98 | } 99 | 100 | #stop-popover, 101 | #between-popover, 102 | #arrivals-popover { 103 | top: 101%; 104 | transition: transform .3s .5s ease-in-out; 105 | max-height: 50vh; 106 | display: flex; 107 | flex-direction: column; 108 | } 109 | #stop-popover.expand, 110 | #between-popover.expand, 111 | #arrivals-popover.expand { 112 | transform: translateY(-101%); 113 | } 114 | #stop-popover header, 115 | #between-popover header { 116 | flex-shrink: 0; 117 | padding: 12px 12px 0; 118 | } 119 | #stop-popover h1, 120 | #between-popover h1 { 121 | font-size: 1em; 122 | margin: 0 0 .5em; 123 | padding: 0; 124 | } 125 | #stop-popover header h1 { 126 | cursor: pointer; 127 | } 128 | #stop-popover h2, 129 | #between-popover h2 { 130 | font-size: .8em; 131 | font-weight: normal; 132 | margin: .5em 0 0; 133 | padding: 0; 134 | color: #999; 135 | text-transform: uppercase; 136 | } 137 | #stop-popover h2 { 138 | margin-top: 0; 139 | margin-bottom: .5em; 140 | } 141 | #stop-popover h2 a { 142 | color: inherit; 143 | text-decoration: none; 144 | } 145 | #stop-popover h2 a:hover { 146 | text-decoration: underline; 147 | } 148 | #stop-popover h2 img.new-window { 149 | filter: invert(1) opacity(.4); 150 | } 151 | #stop-popover h3, 152 | #between-popover h3 { 153 | font-size: .7em; 154 | margin: 1em 0 .5em; 155 | padding: 0; 156 | color: #666; 157 | text-transform: uppercase; 158 | } 159 | #stop-popover p, 160 | #between-popover p { 161 | margin: 0; 162 | padding: 0; 163 | } 164 | #stop-popover .popover-scroll, 165 | #between-popover .popover-scroll { 166 | flex: 1; 167 | /*overflow: auto;*/ 168 | /*-webkit-overflow-scrolling: touch;*/ 169 | padding: 12px; 170 | } 171 | #stop-popover .service-tag { 172 | margin-bottom: 4px; 173 | } 174 | #stop-popover .popover-scroll { 175 | padding-bottom: 0; 176 | padding-top: 0; 177 | } 178 | #between-popover .popover-scroll { 179 | padding-top: 0; 180 | } 181 | #stop-popover .popover-footer { 182 | padding: 12px; 183 | } 184 | @supports (top: max(1px)) { 185 | #between-popover .popover-scroll { 186 | padding-left: max(12px, env(safe-area-inset-left)); 187 | padding-right: max(12px, env(safe-area-inset-right)); 188 | padding-bottom: max(12px, env(safe-area-inset-bottom)); 189 | } 190 | #stop-popover .popover-footer { 191 | padding-left: max(12px, env(safe-area-inset-left)); 192 | padding-right: max(12px, env(safe-area-inset-right)); 193 | padding-bottom: max(12px, env(safe-area-inset-bottom)); 194 | } 195 | } 196 | 197 | #stop-popover .services-list span { 198 | transition: opacity .3s; 199 | } 200 | #stop-popover .services-list.loading span{ 201 | opacity: .75; 202 | pointer-events: none; 203 | } 204 | 205 | #between-popover .between-block { 206 | margin: 1em 0; 207 | } 208 | 209 | #between-popover .between-nada { 210 | font-size: 14px; 211 | color: #333; 212 | } 213 | 214 | #between-popover .between-item { 215 | border-radius: 12px; 216 | padding: 12px; 217 | border: 2px solid transparent; 218 | transition: all .3s ease-in-out; 219 | margin-bottom: 6px; 220 | cursor: pointer; 221 | } 222 | #between-popover .between-item:hover { 223 | border-color: #a4d0ff; 224 | background-color: #fff; 225 | } 226 | #between-popover .between-item.selected { 227 | border-color: #007aff; 228 | box-shadow: 0 0 5px rgba(0,0,0,.15); 229 | background-color: #fff; 230 | } 231 | 232 | #between-popover .between-inner { 233 | pointer-events: none; 234 | position: relative; 235 | height: 40px; 236 | opacity: .6; 237 | transition: opacity .3s ease-in-out; 238 | } 239 | #between-popover .between-item:hover .between-inner, 240 | #between-popover .between-item.selected .between-inner { 241 | opacity: 1; 242 | } 243 | 244 | #between-popover .between-services { 245 | font-size: 14px; 246 | } 247 | #between-popover .between-services span { 248 | position: absolute; 249 | width: 70%; 250 | text-align: center; 251 | display: block; 252 | background-repeat: no-repeat; 253 | background-size: 100% 2px; 254 | background-position: bottom; 255 | } 256 | #between-popover .between-services .start { 257 | padding-right: 2em; 258 | padding-bottom: 2px; 259 | left: 5px; 260 | background-image: linear-gradient(to left, transparent 0%, #f01b48 30%); 261 | } 262 | #between-popover .between-services.full .start { 263 | width: auto; 264 | right: 5px; 265 | } 266 | #between-popover .between-services.full .start { 267 | background-image: linear-gradient(to left, #972FFE, #f01b48); 268 | padding-right: 0; 269 | } 270 | #between-popover .between-services .end { 271 | padding-left: 2em; 272 | padding-bottom: 6px; 273 | right: 5px; 274 | background-image: linear-gradient(to right, transparent 0%, #972FFE 30%); 275 | } 276 | #between-popover .nearby-start .between-services .start:before, 277 | #between-popover .nearby-end .between-services .end:before { 278 | content: ''; 279 | position: absolute; 280 | height: 100%; 281 | width: 32px; 282 | bottom: 0; 283 | border-bottom: 2px dotted #fff; 284 | background-size: 14px; 285 | } 286 | #between-popover .nearby-start .between-services .start:before { 287 | left: 0; 288 | } 289 | #between-popover .nearby-end .between-services .end:before { 290 | right: 0; 291 | } 292 | 293 | #between-popover .between-stops { 294 | position: absolute; 295 | top: 0; 296 | width: 100%; 297 | font-size: 10px; 298 | display: flex; 299 | padding-top: 1.2em; 300 | } 301 | #between-popover .between-stops:before, 302 | #between-popover .between-stops:after { 303 | content: ''; 304 | display: block; 305 | width: 12px; 306 | height: 12px; 307 | border-radius: 100px; 308 | border: 3px solid; 309 | background-color: #fff; 310 | } 311 | #between-popover .between-stops:before { 312 | border-color: #f01b48; 313 | margin-right: 24px; 314 | } 315 | #between-popover .between-stops:after { 316 | border-color: #972FFE; 317 | margin-top: 4px; 318 | margin-left: 24px; 319 | } 320 | #between-popover .between-stops.nada:after { 321 | margin-top: 0; 322 | } 323 | #between-popover .between-stops .start, 324 | #between-popover .between-stops .end { 325 | display: none; 326 | } 327 | 328 | #between-popover .nearby-start .between-stops .start, 329 | #between-popover .nearby-end .between-stops .end { 330 | display: block; 331 | } 332 | #between-popover .nearby-start .between-stops .start:before, 333 | #between-popover .nearby-end .between-stops .end:before { 334 | content: ''; 335 | display: block; 336 | width: 10px; 337 | height: 10px; 338 | border: 3px solid; 339 | border-color: #f01b48 #972FFE #972FFE #f01b48; 340 | background-color: #fff; 341 | border-radius: 100px; 342 | } 343 | #between-popover .nearby-start .between-stops .start:before { 344 | margin-top: 1px; 345 | border-color: #f01b48; 346 | } 347 | #between-popover .nearby-end .between-stops .end:before { 348 | margin-left: auto; 349 | margin-top: 5px; 350 | border-color: #972FFE; 351 | } 352 | 353 | #between-popover .between-stops .betweens { 354 | text-align: center; 355 | display: block; 356 | padding-top: 3px; 357 | flex-grow: 1; 358 | } 359 | #between-popover .between-stops.nada .betweens { 360 | visibility: hidden; 361 | } 362 | #between-popover .between-stops .betweens:before { 363 | content: ''; 364 | display: block; 365 | margin: auto; 366 | width: 10px; 367 | height: 10px; 368 | border: 3px solid; 369 | border-color: #f01b48 #972FFE #972FFE #f01b48; 370 | background-color: #fff; 371 | border-radius: 100px; 372 | } 373 | #between-popover .between-stops .betweens-2:before { 374 | width: 15px; 375 | } 376 | #between-popover .between-stops .betweens-3:before { 377 | width: 20px; 378 | } 379 | #between-popover .between-stops .betweens-4:before { 380 | width: 25px; 381 | } 382 | #between-popover .between-stops .betweens-5:before { 383 | width: 30px; 384 | } 385 | #between-popover .between-stops .betweens-6:before { 386 | width: 35px; 387 | } 388 | 389 | 390 | .popover-list { 391 | flex-grow: 1; 392 | margin: 0; 393 | padding: 0 0 60px; 394 | list-style: none; 395 | /*overflow: auto;*/ 396 | /*-webkit-overflow-scrolling: touch;*/ 397 | } 398 | .popover-list.loading { 399 | opacity: .5; 400 | pointer-events: none; 401 | } 402 | .popover-list.loading #carbonads { 403 | pointer-events: auto; 404 | } 405 | .popover-list.searching #carbonads { 406 | display: none; 407 | } 408 | .popover-list li.nada { 409 | padding: 14px; 410 | pointer-events: none; 411 | } 412 | .popover-list li .item { 413 | cursor: pointer; 414 | padding: 14px; 415 | } 416 | .popover-list li a { 417 | display: flex; 418 | text-decoration: none; 419 | align-items: center; 420 | color: #000; 421 | } 422 | @media (hover: hover) { 423 | .popover-list li .item:hover { 424 | background-color: rgba(255,255,255,.6); 425 | } 426 | } 427 | .popover-list li a [class*="-tag"] { 428 | margin-right: 1em; 429 | } 430 | .popover-list li a.current { 431 | background-color: #dbefb7; 432 | pointer-events: none; 433 | } 434 | 435 | .service-tag { 436 | display: inline-block; 437 | padding: 3px 7px; 438 | border: 2px solid #fff; 439 | border-radius: 5px; 440 | background-color: #95cf29; 441 | color: #fff; 442 | font-weight: bold; 443 | text-decoration: none; 444 | box-shadow: 0 0 5px rgba(0,0,0,.15); 445 | overflow: hidden; 446 | white-space: nowrap; 447 | flex-shrink: 0; 448 | } 449 | a.service-tag:hover { 450 | background-color: #7ac000; 451 | } 452 | .service-tag.current, 453 | .current .service-tag { 454 | border-color: #729e1f; 455 | background-color: #7ac000; 456 | pointer-events: none; 457 | animation: currenting infinite linear 1s alternate both; 458 | } 459 | .service-tag.highlight { 460 | border-color: #729e1f; 461 | animation: currenting infinite linear .5s alternate both; 462 | } 463 | @keyframes currenting { 464 | 0% { 465 | border-color: #729e1f; 466 | } 467 | 100% { 468 | border: 2px solid #fff; 469 | } 470 | } 471 | .service-tag span { 472 | font-weight: normal; 473 | font-size: .75em; 474 | background-color: #fff; 475 | color: #666; 476 | padding: 7px; 477 | margin: 0 -8px 0 5px; 478 | } 479 | 480 | .tag { 481 | margin-top: 7px; 482 | cursor: pointer; 483 | } 484 | 485 | .tag.inactive { 486 | background-color: initial; 487 | border: none; 488 | } 489 | --------------------------------------------------------------------------------