@ -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") |