Added containerization tasks.
@ -0,0 +1,30 @@
|
|||||||
|
from flask import Flask, render_template
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
# list of cat images
|
||||||
|
images = [
|
||||||
|
"https://firebasestorage.googleapis.com/v0/b/docker-curriculum.appspot.com/o/catnip%2F0.gif?alt=media&token=0fff4b31-b3d8-44fb-be39-723f040e57fb",
|
||||||
|
"https://firebasestorage.googleapis.com/v0/b/docker-curriculum.appspot.com/o/catnip%2F1.gif?alt=media&token=2328c855-572f-4a10-af8c-23a6e1db574c",
|
||||||
|
"https://firebasestorage.googleapis.com/v0/b/docker-curriculum.appspot.com/o/catnip%2F10.gif?alt=media&token=647fd422-c8d1-4879-af3e-fea695da79b2",
|
||||||
|
"https://firebasestorage.googleapis.com/v0/b/docker-curriculum.appspot.com/o/catnip%2F11.gif?alt=media&token=900cce1f-55c0-4e02-80c6-ee587d1e9b6e",
|
||||||
|
"https://firebasestorage.googleapis.com/v0/b/docker-curriculum.appspot.com/o/catnip%2F2.gif?alt=media&token=8a108bd4-8dfc-4dbc-9b8c-0db0e626f65b",
|
||||||
|
"https://firebasestorage.googleapis.com/v0/b/docker-curriculum.appspot.com/o/catnip%2F3.gif?alt=media&token=4e270d85-0be3-4048-99bd-696ece8070ea",
|
||||||
|
"https://firebasestorage.googleapis.com/v0/b/docker-curriculum.appspot.com/o/catnip%2F4.gif?alt=media&token=e7daf297-e615-4dfc-aa19-bee959204774",
|
||||||
|
"https://firebasestorage.googleapis.com/v0/b/docker-curriculum.appspot.com/o/catnip%2F5.gif?alt=media&token=a8e472e6-94da-45f9-aab8-d51ec499e5ed",
|
||||||
|
"https://firebasestorage.googleapis.com/v0/b/docker-curriculum.appspot.com/o/catnip%2F7.gif?alt=media&token=9e449089-9f94-4002-a92a-3e44c6bd18a9",
|
||||||
|
"https://firebasestorage.googleapis.com/v0/b/docker-curriculum.appspot.com/o/catnip%2F8.gif?alt=media&token=80a48714-7aaa-45fa-a36b-a7653dc3292b",
|
||||||
|
"https://firebasestorage.googleapis.com/v0/b/docker-curriculum.appspot.com/o/catnip%2F9.gif?alt=media&token=a57a1c71-a8af-4170-8fee-bfe11809f0b3",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
def index():
|
||||||
|
url = random.choice(images)
|
||||||
|
return render_template("index.html", url=url)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 5000)))
|
@ -0,0 +1 @@
|
|||||||
|
Flask==2.0.2
|
@ -0,0 +1,27 @@
|
|||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style type="text/css">
|
||||||
|
body {
|
||||||
|
background: black;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
div.container {
|
||||||
|
max-width: 500px;
|
||||||
|
margin: 100px auto;
|
||||||
|
border: 20px solid white;
|
||||||
|
padding: 10px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
h4 {
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h4>Cat Gif of the day</h4>
|
||||||
|
<img src="{{url}}" />
|
||||||
|
<p><small>Courtesy: <a href="http://www.buzzfeed.com/copyranter/the-best-cat-gif-post-in-the-history-of-cat-gifs">Buzzfeed</a></small></p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
@ -0,0 +1,14 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# build the flask container
|
||||||
|
docker build -t studX/foodtrucks-web ./
|
||||||
|
|
||||||
|
# create the network
|
||||||
|
docker network create foodtrucks-network
|
||||||
|
|
||||||
|
# start the ES container
|
||||||
|
docker run -d --net foodtrucks-network --name elastic elasticsearch:8.4.3
|
||||||
|
|
||||||
|
# start the flask app container
|
||||||
|
# point to elastic host: https://elastic:9200 user: elastic password: v7SLsbtXticPLADei5vS
|
||||||
|
docker run -d -p 80:5000 --net foodtrucks-network --name foodtrucks-web studX/foodtrucks-web https elastic 9200 elastic v7SLsbtXticPLADei5vS
|
@ -0,0 +1,13 @@
|
|||||||
|
version: "3"
|
||||||
|
services:
|
||||||
|
elastic:
|
||||||
|
image: elasticsearch:8.4.3
|
||||||
|
environment:
|
||||||
|
- ELASTIC_PASSWORD=v7SLsbtXticPLADei5vS
|
||||||
|
foodtrucks-web:
|
||||||
|
image: stud15/foodtrucks-web
|
||||||
|
command: https elastic 9200 elastic v7SLsbtXticPLADei5vS
|
||||||
|
depends_on:
|
||||||
|
- elastic
|
||||||
|
ports:
|
||||||
|
- 80:5000
|
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"presets": ["env"]
|
||||||
|
}
|
@ -0,0 +1,134 @@
|
|||||||
|
from elasticsearch import Elasticsearch, exceptions
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from flask import Flask, jsonify, request, render_template
|
||||||
|
import sys
|
||||||
|
import requests
|
||||||
|
|
||||||
|
es_scheme = sys.argv[1]
|
||||||
|
es_host = sys.argv[2]
|
||||||
|
es_port =int(sys.argv[3])
|
||||||
|
es_user = sys.argv[4]
|
||||||
|
es_password = sys.argv[5]
|
||||||
|
|
||||||
|
print(f"Elastic server: {es_scheme}://{es_host}:{es_port}, auth: {es_user}:{es_password}")
|
||||||
|
|
||||||
|
es = Elasticsearch(
|
||||||
|
f"{es_scheme}://{es_host}:{es_port}",
|
||||||
|
basic_auth=(es_user, es_password),
|
||||||
|
verify_certs=False
|
||||||
|
)
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
def load_data_in_es():
|
||||||
|
""" creates an index in elasticsearch """
|
||||||
|
url = "http://data.sfgov.org/resource/rqzj-sfat.json"
|
||||||
|
r = requests.get(url)
|
||||||
|
data = r.json()
|
||||||
|
print("Loading data in elasticsearch ...")
|
||||||
|
for id, truck in enumerate(data):
|
||||||
|
res = es.index(index="sfdata", id=id, body=truck)
|
||||||
|
print("Total trucks loaded: ", len(data))
|
||||||
|
|
||||||
|
def safe_check_index(index, retry=60):
|
||||||
|
""" connect to ES with retry """
|
||||||
|
if not retry:
|
||||||
|
print("Out of retries. Bailing out...")
|
||||||
|
sys.exit(1)
|
||||||
|
try:
|
||||||
|
status = es.indices.exists(index=index)
|
||||||
|
return status
|
||||||
|
except exceptions.ConnectionError as e:
|
||||||
|
print("Unable to connect to ES. Retrying in 5 secs...")
|
||||||
|
time.sleep(5)
|
||||||
|
safe_check_index(index, retry-1)
|
||||||
|
|
||||||
|
def format_fooditems(string):
|
||||||
|
items = [x.strip().lower() for x in string.split(":")]
|
||||||
|
return items[1:] if items[0].find("cold truck") > -1 else items
|
||||||
|
|
||||||
|
def check_and_load_index():
|
||||||
|
""" checks if index exits and loads the data accordingly """
|
||||||
|
if not safe_check_index('sfdata'):
|
||||||
|
print("Index not found...")
|
||||||
|
load_data_in_es()
|
||||||
|
|
||||||
|
###########
|
||||||
|
### APP ###
|
||||||
|
###########
|
||||||
|
@app.route('/')
|
||||||
|
def index():
|
||||||
|
return render_template('index.html')
|
||||||
|
|
||||||
|
@app.route('/debug')
|
||||||
|
def test_es():
|
||||||
|
resp = {}
|
||||||
|
try:
|
||||||
|
msg = es.cat.indices()
|
||||||
|
resp["msg"] = msg
|
||||||
|
resp["status"] = "success"
|
||||||
|
except:
|
||||||
|
resp["status"] = "failure"
|
||||||
|
resp["msg"] = "Unable to reach ES"
|
||||||
|
return jsonify(resp)
|
||||||
|
|
||||||
|
@app.route('/search')
|
||||||
|
def search():
|
||||||
|
key = request.args.get('q')
|
||||||
|
if not key:
|
||||||
|
return jsonify({
|
||||||
|
"status": "failure",
|
||||||
|
"msg": "Please provide a query"
|
||||||
|
})
|
||||||
|
try:
|
||||||
|
res = es.search(
|
||||||
|
index="sfdata",
|
||||||
|
body={
|
||||||
|
"query": {"match": {"fooditems": key}},
|
||||||
|
"size": 750 # max document size
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({
|
||||||
|
"status": "failure",
|
||||||
|
"msg": "error in reaching elasticsearch"
|
||||||
|
})
|
||||||
|
# filtering results
|
||||||
|
vendors = set([x["_source"]["applicant"] for x in res["hits"]["hits"]])
|
||||||
|
temp = {v: [] for v in vendors}
|
||||||
|
fooditems = {v: "" for v in vendors}
|
||||||
|
for r in res["hits"]["hits"]:
|
||||||
|
applicant = r["_source"]["applicant"]
|
||||||
|
if "location" in r["_source"]:
|
||||||
|
truck = {
|
||||||
|
"hours" : r["_source"].get("dayshours", "NA"),
|
||||||
|
"schedule" : r["_source"].get("schedule", "NA"),
|
||||||
|
"address" : r["_source"].get("address", "NA"),
|
||||||
|
"location" : r["_source"]["location"]
|
||||||
|
}
|
||||||
|
fooditems[applicant] = r["_source"]["fooditems"]
|
||||||
|
temp[applicant].append(truck)
|
||||||
|
|
||||||
|
# building up results
|
||||||
|
results = {"trucks": []}
|
||||||
|
for v in temp:
|
||||||
|
results["trucks"].append({
|
||||||
|
"name": v,
|
||||||
|
"fooditems": format_fooditems(fooditems[v]),
|
||||||
|
"branches": temp[v],
|
||||||
|
"drinks": fooditems[v].find("COLD TRUCK") > -1
|
||||||
|
})
|
||||||
|
hits = len(results["trucks"])
|
||||||
|
locations = sum([len(r["branches"]) for r in results["trucks"]])
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"trucks": results["trucks"],
|
||||||
|
"hits": hits,
|
||||||
|
"locations": locations,
|
||||||
|
"status": "success"
|
||||||
|
})
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
ENVIRONMENT_DEBUG = os.environ.get("DEBUG", False)
|
||||||
|
check_and_load_index()
|
||||||
|
app.run(host='0.0.0.0', port=5000, debug=ENVIRONMENT_DEBUG)
|
@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "sf-food",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "SF food app",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "webpack --progress --colors --watch",
|
||||||
|
"build": "NODE_ENV='production' webpack -p",
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"author": "Prakhar Srivastav",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^16.13.1",
|
||||||
|
"react-dom": "^16.13.1",
|
||||||
|
"superagent": "^5.2.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"babel-core": "^6.3.26",
|
||||||
|
"babel-loader": "^6.2.0",
|
||||||
|
"babel-preset-env": "^1.7.0",
|
||||||
|
"babel-preset-es2015": "^6.3.13",
|
||||||
|
"babel-preset-react": "^6.3.13",
|
||||||
|
"webpack": "^1.12.9"
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,3 @@
|
|||||||
|
elasticsearch===8.4.3
|
||||||
|
Flask==2.1.0
|
||||||
|
requests==2.23.0
|
After Width: | Height: | Size: 1.1 KiB |
After Width: | Height: | Size: 8.2 KiB |
After Width: | Height: | Size: 11 KiB |
After Width: | Height: | Size: 1.8 KiB |
After Width: | Height: | Size: 2.8 KiB |
After Width: | Height: | Size: 3.8 KiB |
After Width: | Height: | Size: 5.2 KiB |
After Width: | Height: | Size: 6.2 KiB |
After Width: | Height: | Size: 6.6 KiB |
After Width: | Height: | Size: 8.2 KiB |
After Width: | Height: | Size: 8.8 KiB |
After Width: | Height: | Size: 11 KiB |
After Width: | Height: | Size: 3.2 KiB |
After Width: | Height: | Size: 3.4 KiB |
After Width: | Height: | Size: 3.8 KiB |
After Width: | Height: | Size: 4.0 KiB |
After Width: | Height: | Size: 11 KiB |
After Width: | Height: | Size: 11 KiB |
@ -0,0 +1,2 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<browserconfig><msapplication><tile><square70x70logo src="/ms-icon-70x70.png"/><square150x150logo src="/ms-icon-150x150.png"/><square310x310logo src="/ms-icon-310x310.png"/><TileColor>#ffffff</TileColor></tile></msapplication></browserconfig>
|
After Width: | Height: | Size: 1.2 KiB |
After Width: | Height: | Size: 1.7 KiB |
After Width: | Height: | Size: 5.2 KiB |
After Width: | Height: | Size: 1.1 KiB |
@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"name": "App",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "\/android-icon-36x36.png",
|
||||||
|
"sizes": "36x36",
|
||||||
|
"type": "image\/png",
|
||||||
|
"density": "0.75"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "\/android-icon-48x48.png",
|
||||||
|
"sizes": "48x48",
|
||||||
|
"type": "image\/png",
|
||||||
|
"density": "1.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "\/android-icon-72x72.png",
|
||||||
|
"sizes": "72x72",
|
||||||
|
"type": "image\/png",
|
||||||
|
"density": "1.5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "\/android-icon-96x96.png",
|
||||||
|
"sizes": "96x96",
|
||||||
|
"type": "image\/png",
|
||||||
|
"density": "2.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "\/android-icon-144x144.png",
|
||||||
|
"sizes": "144x144",
|
||||||
|
"type": "image\/png",
|
||||||
|
"density": "3.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "\/android-icon-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image\/png",
|
||||||
|
"density": "4.0"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
After Width: | Height: | Size: 8.2 KiB |
After Width: | Height: | Size: 8.5 KiB |
After Width: | Height: | Size: 22 KiB |
After Width: | Height: | Size: 3.8 KiB |
@ -0,0 +1,50 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom";
|
||||||
|
import Sidebar from "./components/Sidebar";
|
||||||
|
|
||||||
|
// setting up mapbox
|
||||||
|
mapboxgl.accessToken =
|
||||||
|
"pk.eyJ1IjoicHJha2hhciIsImEiOiJjaWZlbzQ1M2I3Nmt2cnhrbnlxcTQyN3VkIn0.uOaUAUqN2VS7dC7XKS0KkQ";
|
||||||
|
|
||||||
|
var map = new mapboxgl.Map({
|
||||||
|
container: "map",
|
||||||
|
style: "mapbox://styles/prakhar/cij2cpsn1004p8ykqqir34jm8",
|
||||||
|
center: [-122.44, 37.77],
|
||||||
|
zoom: 12,
|
||||||
|
});
|
||||||
|
|
||||||
|
ReactDOM.render(<Sidebar map={map} />, document.getElementById("sidebar"));
|
||||||
|
|
||||||
|
function formatHTMLforMarker(props) {
|
||||||
|
var { name, hours, address } = props;
|
||||||
|
var html =
|
||||||
|
'<div class="marker-title">' +
|
||||||
|
name +
|
||||||
|
"</div>" +
|
||||||
|
"<h4>Operating Hours</h4>" +
|
||||||
|
"<span>" +
|
||||||
|
hours +
|
||||||
|
"</span>" +
|
||||||
|
"<h4>Address</h4>" +
|
||||||
|
"<span>" +
|
||||||
|
address +
|
||||||
|
"</span>";
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// setup popup display on the marker
|
||||||
|
map.on("click", function (e) {
|
||||||
|
var features = map.queryRenderedFeatures(
|
||||||
|
e.point,
|
||||||
|
{ layers: ['trucks', 'trucks-highlight'], radius: 10, includeGeometry: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!features.length) return;
|
||||||
|
|
||||||
|
var feature = features[0];
|
||||||
|
|
||||||
|
new mapboxgl.Popup()
|
||||||
|
.setLngLat(feature.geometry.coordinates)
|
||||||
|
.setHTML(formatHTMLforMarker(feature.properties))
|
||||||
|
.addTo(map);
|
||||||
|
});
|
@ -0,0 +1,36 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export default function Intro() {
|
||||||
|
return (
|
||||||
|
<div className="intro">
|
||||||
|
<h3>About</h3>
|
||||||
|
<p>
|
||||||
|
This is a fun application built to accompany the{" "}
|
||||||
|
<a href="http://prakhar.me/docker-curriculum">docker curriculum</a> - a
|
||||||
|
comprehensive tutorial on getting started with Docker targeted
|
||||||
|
especially at beginners.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
The app is built with Flask on the backend and Elasticsearch is the
|
||||||
|
engine powering the search.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
The frontend is hand-crafted with React and the beautiful maps are
|
||||||
|
courtesy of Mapbox.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
If you find the design a bit ostentatious, blame{" "}
|
||||||
|
<a href="http://genius.com/Justin-bieber-baby-lyrics">Genius</a> for
|
||||||
|
giving me the idea of using this color scheme. If you love it, I smugly
|
||||||
|
take all the credit. ⊂(▀¯▀⊂)
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Lastly, the data for the food trucks is made available in public domain
|
||||||
|
by{" "}
|
||||||
|
<a href="https://data.sfgov.org/Economy-and-Community/Mobile-Food-Facility-Permit/rqzj-sfat">
|
||||||
|
SF Data
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,205 @@
|
|||||||
|
import React from "react";
|
||||||
|
import request from "superagent";
|
||||||
|
import Intro from "./Intro";
|
||||||
|
import Vendor from "./Vendor";
|
||||||
|
|
||||||
|
class Sidebar extends React.Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
results: [],
|
||||||
|
query: "",
|
||||||
|
firstLoad: true,
|
||||||
|
};
|
||||||
|
this.onChange = this.onChange.bind(this);
|
||||||
|
this.handleSearch = this.handleSearch.bind(this);
|
||||||
|
this.handleHover = this.handleHover.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchResults() {
|
||||||
|
request.get("/search?q=" + this.state.query).end((err, res) => {
|
||||||
|
if (err) {
|
||||||
|
alert("error in fetching response");
|
||||||
|
} else {
|
||||||
|
this.setState({
|
||||||
|
results: res.body,
|
||||||
|
firstLoad: false,
|
||||||
|
});
|
||||||
|
this.plotOnMap();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
generateGeoJSON(markers) {
|
||||||
|
return {
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: markers.map((p) => ({
|
||||||
|
type: "Feature",
|
||||||
|
properties: {
|
||||||
|
name: p.name,
|
||||||
|
hours: p.hours,
|
||||||
|
address: p.address,
|
||||||
|
"point-color": "253,237,57,1",
|
||||||
|
},
|
||||||
|
geometry: {
|
||||||
|
type: "Point",
|
||||||
|
coordinates: [
|
||||||
|
parseFloat(p.location.longitude),
|
||||||
|
parseFloat(p.location.latitude),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
plotOnMap(vendor) {
|
||||||
|
const map = this.props.map;
|
||||||
|
const results = this.state.results;
|
||||||
|
const markers = [].concat.apply(
|
||||||
|
[],
|
||||||
|
results.trucks.map((t) =>
|
||||||
|
t.branches.map((b) => ({
|
||||||
|
location: b.location,
|
||||||
|
name: t.name,
|
||||||
|
schedule: b.schedule,
|
||||||
|
hours: b.hours,
|
||||||
|
address: b.address,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
var highlightMarkers, usualMarkers, usualgeoJSON, highlightgeoJSON;
|
||||||
|
|
||||||
|
if (vendor) {
|
||||||
|
highlightMarkers = markers.filter(
|
||||||
|
(m) => m.name.toLowerCase() === vendor.toLowerCase()
|
||||||
|
);
|
||||||
|
usualMarkers = markers.filter(
|
||||||
|
(m) => m.name.toLowerCase() !== vendor.toLowerCase()
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
usualMarkers = markers;
|
||||||
|
}
|
||||||
|
|
||||||
|
usualgeoJSON = this.generateGeoJSON(usualMarkers);
|
||||||
|
if (highlightMarkers) {
|
||||||
|
highlightgeoJSON = this.generateGeoJSON(highlightMarkers);
|
||||||
|
}
|
||||||
|
// clearing layers
|
||||||
|
if (map.getLayer("trucks")) {
|
||||||
|
map.removeLayer("trucks");
|
||||||
|
}
|
||||||
|
if (map.getSource("trucks")) {
|
||||||
|
map.removeSource("trucks");
|
||||||
|
}
|
||||||
|
if (map.getLayer("trucks-highlight")) {
|
||||||
|
map.removeLayer("trucks-highlight");
|
||||||
|
}
|
||||||
|
if (map.getSource("trucks-highlight")) {
|
||||||
|
map.removeSource("trucks-highlight");
|
||||||
|
}
|
||||||
|
|
||||||
|
map
|
||||||
|
.addSource("trucks", {
|
||||||
|
type: "geojson",
|
||||||
|
data: usualgeoJSON,
|
||||||
|
})
|
||||||
|
.addLayer({
|
||||||
|
id: "trucks",
|
||||||
|
type: "circle",
|
||||||
|
interactive: true,
|
||||||
|
source: "trucks",
|
||||||
|
paint: {
|
||||||
|
"circle-radius": 8,
|
||||||
|
"circle-color": "rgba(253,237,57,1)",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (highlightMarkers) {
|
||||||
|
map
|
||||||
|
.addSource("trucks-highlight", {
|
||||||
|
type: "geojson",
|
||||||
|
data: highlightgeoJSON,
|
||||||
|
})
|
||||||
|
.addLayer({
|
||||||
|
id: "trucks-highlight",
|
||||||
|
type: "circle",
|
||||||
|
interactive: true,
|
||||||
|
source: "trucks-highlight",
|
||||||
|
paint: {
|
||||||
|
"circle-radius": 8,
|
||||||
|
"circle-color": "rgba(164,65,99,1)",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSearch(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.fetchResults();
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange(e) {
|
||||||
|
this.setState({ query: e.target.value });
|
||||||
|
}
|
||||||
|
|
||||||
|
handleHover(vendorName) {
|
||||||
|
this.plotOnMap(vendorName);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.firstLoad) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div id="search-area">
|
||||||
|
<form onSubmit={this.handleSearch}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={this.state.query}
|
||||||
|
onChange={this.onChange}
|
||||||
|
placeholder="Burgers, Tacos or Wraps?"
|
||||||
|
/>
|
||||||
|
<button>Search!</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<Intro />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = this.state.query;
|
||||||
|
const resultsCount = this.state.results.hits || 0;
|
||||||
|
const locationsCount = this.state.results.locations || 0;
|
||||||
|
const results = this.state.results.trucks || [];
|
||||||
|
const renderedResults = results.map((r, i) => (
|
||||||
|
<Vendor key={i} data={r} handleHover={this.handleHover} />
|
||||||
|
));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div id="search-area">
|
||||||
|
<form onSubmit={this.handleSearch}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={this.onChange}
|
||||||
|
placeholder="Burgers, Tacos or Wraps?"
|
||||||
|
/>
|
||||||
|
<button>Search!</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{resultsCount > 0 ? (
|
||||||
|
<div id="results-area">
|
||||||
|
<h5>
|
||||||
|
Found <span className="highlight">{resultsCount}</span> vendors in{" "}
|
||||||
|
<span className="highlight">{locationsCount}</span> different
|
||||||
|
locations
|
||||||
|
</h5>
|
||||||
|
<ul> {renderedResults} </ul>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Sidebar;
|
@ -0,0 +1,70 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export default class Vendor extends React.Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
isExpanded: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.toggleExpand = this.toggleExpand.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
formatFoodItems(items) {
|
||||||
|
if (this.state.isExpanded) {
|
||||||
|
return items.join(", ");
|
||||||
|
}
|
||||||
|
const summary = items.join(", ").substr(0, 80);
|
||||||
|
if (summary.length > 70) {
|
||||||
|
const indexOfLastSpace =
|
||||||
|
summary.split("").reverse().join("").indexOf(",") + 1;
|
||||||
|
return summary.substr(0, 80 - indexOfLastSpace) + " & more...";
|
||||||
|
}
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleExpand() {
|
||||||
|
this.setState({
|
||||||
|
isExpanded: !this.state.isExpanded,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { name, branches, fooditems, drinks } = this.props.data;
|
||||||
|
const servesDrinks = (
|
||||||
|
<div className="row">
|
||||||
|
<div className="icons">
|
||||||
|
{" "}
|
||||||
|
<i className="ion-wineglass"></i>{" "}
|
||||||
|
</div>
|
||||||
|
<div className="content">Serves Cold Drinks</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
onMouseEnter={this.props.handleHover.bind(null, name)}
|
||||||
|
onClick={this.toggleExpand}
|
||||||
|
>
|
||||||
|
<p className="truck-name">{name}</p>
|
||||||
|
<div className="row">
|
||||||
|
<div className="icons">
|
||||||
|
{" "}
|
||||||
|
<i className="ion-android-pin"></i>{" "}
|
||||||
|
</div>
|
||||||
|
<div className="content"> {branches.length} locations </div>
|
||||||
|
</div>
|
||||||
|
{drinks ? servesDrinks : null}
|
||||||
|
<div className="row">
|
||||||
|
<div className="icons">
|
||||||
|
{" "}
|
||||||
|
<i className="ion-fork"></i> <i className="ion-spoon"></i>
|
||||||
|
</div>
|
||||||
|
<div className="content">
|
||||||
|
Serves {this.formatFoodItems(fooditems)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,202 @@
|
|||||||
|
html,
|
||||||
|
body {
|
||||||
|
padding: 0;
|
||||||
|
color: #aaa;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-family: "Titillium Web", sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.github-corner:hover .octo-arm {
|
||||||
|
animation: octocat-wave 560ms ease-in-out;
|
||||||
|
}
|
||||||
|
@keyframes octocat-wave {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: rotate(0);
|
||||||
|
}
|
||||||
|
20%,
|
||||||
|
60% {
|
||||||
|
transform: rotate(-25deg);
|
||||||
|
}
|
||||||
|
40%,
|
||||||
|
80% {
|
||||||
|
transform: rotate(10deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 500px) {
|
||||||
|
.github-corner:hover .octo-arm {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
.github-corner .octo-arm {
|
||||||
|
animation: octocat-wave 560ms ease-in-out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
i {
|
||||||
|
color: #3a3a3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.intro a,
|
||||||
|
div.intro a:visited {
|
||||||
|
color: #fded39;
|
||||||
|
}
|
||||||
|
div.intro {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.intro h3 {
|
||||||
|
color: #fded39;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 i {
|
||||||
|
color: black;
|
||||||
|
text-shadow: none;
|
||||||
|
font-size: 21px;
|
||||||
|
}
|
||||||
|
textarea,
|
||||||
|
input,
|
||||||
|
button {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
height: 90vh;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
#map {
|
||||||
|
flex: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
#sidebar {
|
||||||
|
background: #1a1a1a;
|
||||||
|
border-left: 1px solid #444444;
|
||||||
|
width: 320px;
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
#sidebar div#results-area {
|
||||||
|
overflow-y: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
div#heading {
|
||||||
|
background: #fded39;
|
||||||
|
margin: 0;
|
||||||
|
height: 10vh;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
div#heading h1 {
|
||||||
|
font-size: 25px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
text-shadow: -2px 2px black;
|
||||||
|
margin: 0;
|
||||||
|
color: #fded39;
|
||||||
|
}
|
||||||
|
|
||||||
|
div#heading p {
|
||||||
|
color: black;
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 12px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
div#search-area {
|
||||||
|
padding: 10px;
|
||||||
|
border-bottom: 1px solid #444444;
|
||||||
|
}
|
||||||
|
|
||||||
|
div#search-area input,
|
||||||
|
div#search-area button {
|
||||||
|
padding: 10px 8px;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
div#search-area input {
|
||||||
|
width: 195px;
|
||||||
|
}
|
||||||
|
div#search-area button {
|
||||||
|
background: #fded39;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
div#results-area {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
div#results-area h5 {
|
||||||
|
font-weight: 200;
|
||||||
|
font-style: italic;
|
||||||
|
margin: 0;
|
||||||
|
color: #ddd;
|
||||||
|
}
|
||||||
|
div#results-area h5 span.highlight {
|
||||||
|
color: #fded39;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
div#results-area ul {
|
||||||
|
padding: 0;
|
||||||
|
list-style-type: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
div#results-area ul li {
|
||||||
|
border: 1px solid #444;
|
||||||
|
padding: 5px 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
div#results-area ul li:hover {
|
||||||
|
border: 1px solid #fded39;
|
||||||
|
}
|
||||||
|
|
||||||
|
div#results-area ul li p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
div#results-area ul li p.truck-name {
|
||||||
|
color: #fded39;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
div#results-area ul li div.row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
div#results-area ul li div.row div.icons {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
div#results-area ul li div.row div.content {
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapboxgl-popup-content {
|
||||||
|
background: black;
|
||||||
|
font-family: "Titillium Web", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapboxgl-popup-anchor-bottom .mapboxgl-popup-tip {
|
||||||
|
border-top-color: black;
|
||||||
|
}
|
||||||
|
.mapboxgl-popup-anchor-top .mapboxgl-popup-tip {
|
||||||
|
border-bottom-color: black;
|
||||||
|
}
|
||||||
|
.mapboxgl-popup-close-button {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.mapboxgl-popup-content .marker-title {
|
||||||
|
color: #fded39;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.mapboxgl-popup-content h4 {
|
||||||
|
margin: 0;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
@ -0,0 +1,50 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset='utf-8' />
|
||||||
|
<title>SF Food Trucks</title>
|
||||||
|
<meta name='viewport' content='initial-scale=1,maximum-scale=1,user-scalable=no' />
|
||||||
|
<link href='https://fonts.googleapis.com/css?family=Titillium+Web:400,700' rel='stylesheet' type='text/css'>
|
||||||
|
<script src='https://api.mapbox.com/mapbox-gl-js/v1.9.1/mapbox-gl.js'></script>
|
||||||
|
<link href='https://api.mapbox.com/mapbox-gl-js/v1.9.1/mapbox-gl.css' rel='stylesheet' />
|
||||||
|
<link href='/static/styles/main.css' rel='stylesheet' />
|
||||||
|
<link rel="stylesheet" href="http://code.ionicframework.com/ionicons/2.0.1/css/ionicons.min.css" />
|
||||||
|
<link rel="apple-touch-icon" sizes="57x57" href="/static/icons/apple-icon-57x57.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="60x60" href="/static/icons/apple-icon-60x60.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="72x72" href="/static/icons/apple-icon-72x72.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="76x76" href="/static/icons/apple-icon-76x76.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="114x114" href="/static/icons/apple-icon-114x114.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="120x120" href="/static/icons/apple-icon-120x120.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="144x144" href="/static/icons/apple-icon-144x144.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="152x152" href="/static/icons/apple-icon-152x152.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/static/icons/apple-icon-180x180.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="192x192" href="/static/icons//android-icon-192x192.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/static/icons/favicon-32x32.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="96x96" href="/static/icons/favicon-96x96.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="/static/icons/favicon-16x16.png">
|
||||||
|
<meta name="msapplication-TileColor" content="#ffffff">
|
||||||
|
<meta name="msapplication-TileImage" content="/ms-icon-144x144.png">
|
||||||
|
<meta name="theme-color" content="#ffffff">
|
||||||
|
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- awesome svg octocat thanks to http://tholman.com/ -->
|
||||||
|
<a href="https://github.com/prakhar1989/FoodTrucks/" class="github-corner" title="Fork me on Github">
|
||||||
|
<svg width="72" height="72" viewBox="0 0 250 250" style="fill:#000; color:#FDED39; position: absolute; top: 0; border: 0; left: 0; transform: scale(-1, 1);">
|
||||||
|
<path d="M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z"></path>
|
||||||
|
<path d="M128.3,109.0 C113.8,99.7 119.0,89.6 119.0,89.6 C122.0,82.7 120.5,78.6 120.5,78.6 C119.2,72.0 123.4,76.3 123.4,76.3 C127.3,80.9 125.5,87.3 125.5,87.3 C122.9,97.6 130.6,101.9 134.4,103.2" fill="currentColor" style="transform-origin: 130px 106px;" class="octo-arm"></path>
|
||||||
|
<path d="M115.0,115.0 C114.9,115.1 118.7,116.5 119.8,115.4 L133.7,101.6 C136.9,99.2 139.9,98.4 142.2,98.6 C133.8,88.0 127.5,74.4 143.8,58.0 C148.5,53.4 154.0,51.2 159.7,51.0 C160.3,49.4 163.2,43.6 171.4,40.1 C171.4,40.1 176.1,42.5 178.8,56.2 C183.1,58.6 187.2,61.8 190.9,65.4 C194.5,69.0 197.7,73.2 200.1,77.6 C213.8,80.2 216.3,84.9 216.3,84.9 C212.7,93.1 206.9,96.0 205.4,96.6 C205.1,102.4 203.0,107.8 198.3,112.5 C181.9,128.9 168.3,122.5 157.7,114.1 C157.9,116.9 156.7,120.9 152.7,124.9 L141.0,136.5 C139.8,137.7 141.6,141.9 141.8,141.8 Z" fill="currentColor" class="octo-body"></path>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<div id="heading">
|
||||||
|
<h1>SF F <i class="ion-pizza"></i> <i class="ion-icecream"></i> d Trucks</h1>
|
||||||
|
<p>San Francisco's finger-licking street food now at your fingertips.</p>
|
||||||
|
</div>
|
||||||
|
<div class="container">
|
||||||
|
<div id='map'></div>
|
||||||
|
<div id="sidebar"> </div>
|
||||||
|
</div>
|
||||||
|
<script src='/static/build/main.js'></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
@ -0,0 +1,19 @@
|
|||||||
|
module.exports = {
|
||||||
|
cache: true,
|
||||||
|
entry: './static/src/app.js',
|
||||||
|
output: {
|
||||||
|
filename: './static/build/main.js'
|
||||||
|
},
|
||||||
|
devtool: 'source-map',
|
||||||
|
module: {
|
||||||
|
loaders: [
|
||||||
|
{
|
||||||
|
test: /\.js$/,
|
||||||
|
loader: 'babel-loader',
|
||||||
|
query: {
|
||||||
|
presets: ['es2015', 'react']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
@ -0,0 +1,46 @@
|
|||||||
|
import json
|
||||||
|
import requests
|
||||||
|
|
||||||
|
def getData(url):
|
||||||
|
r = requests.get(url)
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
def convertData(data, msymbol="restaurant", msize="medium"):
|
||||||
|
data_dict = []
|
||||||
|
for d in data:
|
||||||
|
if d.get('longitude') and d.get("latitude"):
|
||||||
|
data_dict.append({
|
||||||
|
"type": "Feature",
|
||||||
|
"geometry": {
|
||||||
|
"type": "Point",
|
||||||
|
"coordinates": [float(d["longitude"]),
|
||||||
|
float(d["latitude"])]
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
"name": d.get("applicant", ""),
|
||||||
|
"marker-symbol": msymbol,
|
||||||
|
"marker-size": msize,
|
||||||
|
"marker-color": "#CC0033",
|
||||||
|
"fooditems": d.get('fooditems', ""),
|
||||||
|
"address": d.get("address", "")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return data_dict
|
||||||
|
|
||||||
|
def writeToFile(data, filename="data.geojson"):
|
||||||
|
template = {
|
||||||
|
"type": "FeatureCollection",
|
||||||
|
"crs": {
|
||||||
|
"type": "name",
|
||||||
|
"properties": {
|
||||||
|
"name": "urn:ogc:def:crs:OGC:1.3:CRS84"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"features": data }
|
||||||
|
with open(filename, "w") as f:
|
||||||
|
json.dump(template, f, indent=2)
|
||||||
|
print "Geojson generated"
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
data = getData("http://data.sfgov.org/resource/rqzj-sfat.json")
|
||||||
|
writeToFile(convertData(data[:350]), filename="trucks.geojson")
|