@ -0,0 +1,6 @@ | |||
*.swp | |||
node_modules | |||
venv | |||
.DS_Store | |||
elasticsearch | |||
*.pem |
@ -0,0 +1,25 @@ | |||
# start from base | |||
FROM ubuntu:18.04 | |||
LABEL maintainer="Prakhar Srivastav <prakhar@prakhar.me>" | |||
# install system-wide deps for python and node | |||
RUN apt-get -yqq update | |||
RUN apt-get -yqq install python3-pip python3-dev curl gnupg | |||
RUN curl -sL https://deb.nodesource.com/setup_10.x | bash | |||
RUN apt-get install -yq nodejs | |||
# copy our application code | |||
ADD flask-app /opt/flask-app | |||
WORKDIR /opt/flask-app | |||
# fetch app specific deps | |||
RUN npm install | |||
RUN npm run build | |||
RUN pip3 install -r requirements.txt | |||
# expose port | |||
EXPOSE 5000 | |||
# start app | |||
CMD [ "python3", "./app.py" ] |
@ -0,0 +1,30 @@ | |||
version: '2' | |||
services: | |||
es: | |||
image: docker.elastic.co/elasticsearch/elasticsearch:7.6.2 | |||
cpu_shares: 100 | |||
mem_limit: 3621440000 | |||
environment: | |||
- discovery.type=single-node | |||
- bootstrap.memory_lock=true | |||
- "ES_JAVA_OPTS=-Xms512m -Xmx512m" | |||
logging: | |||
driver: awslogs | |||
options: | |||
awslogs-group: foodtrucks | |||
awslogs-region: us-east-1 | |||
awslogs-stream-prefix: es | |||
web: | |||
image: prakhar1989/foodtrucks-web | |||
cpu_shares: 100 | |||
mem_limit: 262144000 | |||
ports: | |||
- "80:5000" | |||
links: | |||
- es | |||
logging: | |||
driver: awslogs | |||
options: | |||
awslogs-group: foodtrucks | |||
awslogs-region: us-east-1 | |||
awslogs-stream-prefix: web |
@ -0,0 +1,23 @@ | |||
version: "3" | |||
services: | |||
es: | |||
image: docker.elastic.co/elasticsearch/elasticsearch:6.3.2 | |||
container_name: es | |||
environment: | |||
- discovery.type=single-node | |||
ports: | |||
- 9200:9200 | |||
volumes: | |||
- esdata1:/usr/share/elasticsearch/data | |||
web: | |||
image: prakhar1989/foodtrucks-web | |||
command: python3 app.py | |||
depends_on: | |||
- es | |||
ports: | |||
- 5000:5000 | |||
volumes: | |||
- ./flask-app:/opt/flask-app | |||
volumes: | |||
esdata1: | |||
driver: local |
@ -0,0 +1,3 @@ | |||
{ | |||
"presets": ["env"] | |||
} |
@ -0,0 +1,122 @@ | |||
from elasticsearch import Elasticsearch, exceptions | |||
import os | |||
import time | |||
from flask import Flask, jsonify, request, render_template | |||
import sys | |||
import requests | |||
es = Elasticsearch(host='es') | |||
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", doc_type="truck", id=id, body=truck) | |||
print("Total trucks loaded: ", len(data)) | |||
def safe_check_index(index, retry=3): | |||
""" connect to ES with retry """ | |||
if not retry: | |||
print("Out of retries. Bailing out...") | |||
sys.exit(1) | |||
try: | |||
status = es.indices.exists(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>=7.0.0,<8.0.0 | |||
Flask==1.1.2 | |||
requests==2.23.0 |
@ -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> |
@ -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" | |||
} | |||
] | |||
} |
@ -0,0 +1,68 @@ | |||
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) { | |||
map.featuresAt( | |||
e.point, | |||
{ layer: "trucks", radius: 10, includeGeometry: true }, | |||
function (err, features) { | |||
if (err || !features.length) return; | |||
var feature = features[0]; | |||
new mapboxgl.Popup() | |||
.setLngLat(feature.geometry.coordinates) | |||
.setHTML(formatHTMLforMarker(feature.properties)) | |||
.addTo(map); | |||
} | |||
); | |||
}); | |||
map.on("click", function (e) { | |||
map.featuresAt( | |||
e.point, | |||
{ layer: "trucks-highlight", radius: 10, includeGeometry: true }, | |||
function (err, features) { | |||
if (err || !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.coordinates[0]), | |||
parseFloat(p.location.coordinates[1]), | |||
], | |||
}, | |||
})), | |||
}; | |||
} | |||
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,13 @@ | |||
#!/bin/bash | |||
# configure | |||
ecs-cli configure --region us-east-1 --cluster foodtrucks | |||
# setup cloud formation template | |||
ecs-cli up --keypair ecs --capability-iam --size 1 --instance-type t2.medium | |||
# deploy | |||
cd aws-ecs && ecs-cli compose --file aws-compose.yml up | |||
# check | |||
ecs-cli ps |
@ -0,0 +1,13 @@ | |||
#!/bin/bash | |||
# build the flask container | |||
docker build -t prakhar1989/foodtrucks-web . | |||
# create the network | |||
docker network create foodtrucks-net | |||
# start the ES container | |||
docker run -d --name es --net foodtrucks-net -p 9200:9200 -p 9300:9300 -e "discovery.type=single-node" docker.elastic.co/elasticsearch/elasticsearch:6.3.2 | |||
# start the flask app container | |||
docker run -d --net foodtrucks-net -p 5000:5000 --name foodtrucks-web prakhar1989/foodtrucks-web |
@ -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") |