├── 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 |
27 | You need to enable JavaScript to run this app.
28 |
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 |
46 |
47 | {
48 | districts.map((item, index) => (
49 |
55 | ))
56 | }
57 |
58 |
59 | )
60 | }
61 | {
62 | filter === 3 && !loading && !error && (
63 |
64 |
65 | {
66 | communes.map((item, index) => (
67 | history.push(`/communes/${item.id}`)}>
68 |
69 | {item.name}
70 |
71 |
{item.district} · {item.region}
72 |
73 | ))
74 | }
75 |
76 |
77 | )
78 | }
79 | {
80 | filter === 4 && !loading && !error && (
81 |
82 |
83 | {
84 | fokontany.map((item, index) => (
85 | history.push(`/fokontany/${item.id}`)}>
86 |
87 | {item.name}
88 |
89 |
{item.commune} · {item.district} · {item.region}
90 |
91 | ))
92 | }
93 |
94 |
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 |
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 |
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 |
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 |
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 |
11 |
12 |
13 |
14 |
15 |
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 | [](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 | [](https://mg-explorer.netlify.com/)
28 | [](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 |
--------------------------------------------------------------------------------