다양한 가격대의 서울시내 스시야를 시각화한 지도.
맛집 이름들이 겹치지 않게 하기 위해 forced layout을 적용했습니다.
- 데이터 수집: 수작업
- Author: Lucy Park
- License: Apache v2
다양한 가격대의 서울시내 스시야를 시각화한 지도.
맛집 이름들이 겹치지 않게 하기 위해 forced layout을 적용했습니다.
#! /usr/bin/python3 | |
# -*- coding: utf-8 -*- | |
import json | |
from lxml import html | |
import requests | |
MAPAPI = 'http://openapi.map.naver.com/api/geocode.php?key=2994b9957130f5a19b0aa0165bb9fd1e&encoding=utf-8&coord=LatLng&query=%s' | |
def get_latlon(query): | |
root = html.parse(MAPAPI % query) | |
lon, lat = root.xpath('//point/x/text()')[0], root.xpath('//point/y/text()')[0] | |
return (lat, lon) | |
def prep(item): | |
n, name = item[0].split(' ', 1) | |
lat, lon = get_latlon(item[3]) | |
return { | |
'num': n, 'name': name, | |
'lat': lat, 'lon': lon, | |
'description': item[1], | |
'phone': item[2], | |
'addr': item[3] | |
} | |
url = 'http://m.wikitree.co.kr/main/news_view.php?id=217101' | |
query = '서울시 서대문구 창천동 72-36' | |
r = requests.get(url) | |
root = html.document_fromstring(r.text) | |
string = '\n'.join(root.xpath('//div[@id="ct_size"]/div//text()')) | |
items = [] | |
for i in range(1, 21): | |
tmp = string.split('%s.' % i, 1) | |
string = tmp[1] | |
items.append([j.strip() for j in tmp[0].split('\n') if j and j!='\xa0']) | |
data = [prep(i[:4]) for i in items[1:]] | |
with open('places.json', 'w') as f: | |
json.dump(data, f) | |
with open('places2.csv', 'w') as f: | |
f.write('type,name,prince,lat,lon,url\n') | |
for d in data: | |
f.write('siksin,%(name)s,0,%(lat)s,%(lon)s,\n' % d) |
<!DOCTYPE html> | |
<html> | |
<head> | |
<meta charset="utf-8"> | |
<style> | |
svg circle { | |
opacity: .5; | |
stroke: white; | |
} | |
svg circle:hover { | |
stroke: #333; | |
} | |
svg .axis line, svg .axis path { | |
fill: none; | |
stroke: #000; | |
shape-rendering: crispEdges; | |
} | |
svg .axis text { | |
font: 10px sans-serif; | |
} | |
svg .municipality { | |
fill: #efefef; | |
stroke: #fff; | |
} | |
svg .municipality-label { | |
fill: #bbb; | |
font-size: 12px; | |
font-weight: 300; | |
text-anchor: middle; | |
} | |
svg #map text { | |
color: #333; | |
font-size: 10px; | |
pointer-events: none; | |
text-anchor: middle; | |
} | |
svg #places text { | |
color: #777; | |
font: 10px sans-serif; | |
text-anchor: start; | |
} | |
#title { | |
position: absolute; | |
top: 10px; | |
left: 650px; | |
width: 300px; | |
font-family: sans-serif; | |
text-align: right; | |
} | |
#title p { | |
font-size: 10pt; | |
} | |
</style> | |
</head> | |
<body> | |
<div id="title"> | |
<h2>서울시 스시야 기행</h2> | |
<p><a href="http://redfish.egloos.com">레드피쉬</a>, <a href="http://hsong.egloos.com">녹두장군</a> 등 유명 맛집블로거들이 추천하는 서울 시내 스시집! | |
점의 색은 스시야의 가격대를 나타내고, 점을 클릭하면 해당 스시야에 대한 소개글로 이동합니다. | |
하나씩 정복해봐요... >_<</p> | |
<p> | |
<a href="https://gist.github.com/e9t/85fcfb53db389696624f">Code</a> | |
by <a href="http://lucypark.kr">Lucy Park</a>. | |
<br> | |
<a href="http://opensource.org/licenses/Apache-2.0">Licensed with Apache 2.0</a> | |
</p> | |
</div> | |
<div id="chart"></div> | |
<script src="http://d3js.org/d3.v3.min.js"></script> | |
<script src="http://d3js.org/queue.v1.min.js"></script> | |
<script src="http://d3js.org/topojson.v1.min.js"></script> | |
<script> | |
// set params | |
var width = 960, | |
height = 500; | |
var minColor = 'yellow', | |
maxColor = 'red'; | |
var minValue = 5, | |
maxValue = 35; // TODO: automate | |
var legendWidth = 15, | |
legendHeight = 150, | |
margin = { left: 40, top: 30 }; | |
// define color scale | |
var colorScale = d3.scale.linear() | |
.range([minColor, maxColor]) // or use hex values | |
.domain([minValue, maxValue]); | |
// define projection and path | |
var projection = d3.geo.mercator() | |
.center([126.9895, 37.5651]) | |
.scale(80000) | |
.translate([2*width/5, height/2]); | |
var path = d3.geo.path().projection(projection); | |
// add canvas | |
var svg = d3.select("#chart").append("svg") | |
.attr("width", width) | |
.attr("height", height); | |
var map = svg.append("g").attr("id", "map"), | |
points = svg.append("g").attr("id", "places"), | |
legend = svg.append("g").attr("id", "legend"); | |
// add legend for colors | |
var legendBar = legend.append("defs").append("linearGradient") | |
.attr("id", "gradient") | |
.attr("x1", "100%") | |
.attr("y1", "0%") | |
.attr("x2", "100%") | |
.attr("y2", "100%") | |
.attr("spreadMethod", "pad"); | |
legendBar.append("stop") | |
.attr("offset", "0%") | |
.attr("stop-color", maxColor) | |
.attr("stop-opacity", 1); | |
legendBar.append("stop") | |
.attr("offset", "100%") | |
.attr("stop-color", minColor) | |
.attr("stop-opacity", 1); | |
legend.append("rect") | |
.attr("width", legendWidth) | |
.attr("height", legendHeight) | |
.style("fill", "url(#gradient)") | |
.style("opacity", 0.5) | |
.attr("transform", "translate(" + margin.left + "," + margin.top + ")"); | |
var y = d3.scale.linear().range([legendHeight, 0]).domain([minValue, maxValue]); | |
var yAxis = d3.svg.axis().scale(y).orient("right"); | |
legend.append("g") | |
.attr("class", "axis") | |
.attr("transform", "translate(" + (legendWidth + margin.left) + "," + margin.top + ")") | |
.call(yAxis) | |
.append("text") | |
.attr("transform", "rotate(-90)") | |
.attr("y", 30) | |
.attr("dy", ".71em") | |
.style("text-anchor", "end") | |
.text("(단위: 만원)"); | |
// add map | |
d3.json("seoul_municipalities_topo_simple.json", function(error, data) { | |
var features = topojson.feature(data, data.objects.seoul_municipalities_geo).features; | |
map.selectAll('path') | |
.data(features) | |
.enter().append('path') | |
.attr('class', function(d) { console.log(); return 'municipality c' + d.properties.code }) | |
.attr('d', path); | |
map.selectAll('text') | |
.data(features) | |
.enter().append("text") | |
.attr("transform", function(d) { return "translate(" + path.centroid(d) + ")"; }) | |
.attr("dy", ".35em") | |
.attr("class", "municipality-label") | |
.text(function(d) { return d.properties.name; }) | |
}); | |
// add circles | |
d3.csv("places.csv", function(data) { | |
var point = points.selectAll("circle") | |
.data(data.filter(function(d) { return d.type==="sushi"; })) | |
.enter().append("a") | |
.attr("xlink:href", function(d) { return d.url }); | |
point.append("circle") | |
.attr("cx", function(d) { return projection([d.lon, d.lat])[0]; }) | |
.attr("cy", function(d) { return projection([d.lon, d.lat])[1]; }) | |
.attr("r", 6) | |
.style("fill", function(d) { return colorScale(d.price); }); | |
// add circle labels | |
var labels = [], | |
labelLinks = []; | |
data.forEach(function(d, i) { | |
var p = projection([d.lon, d.lat]); | |
var node = { | |
label: d.name, | |
x: p[0], | |
y: p[1] | |
}; | |
labels.push({node : node }); labels.push({node : node }); // push twice | |
labelLinks.push({ source : i * 2, target : i * 2 + 1, weight : 1, x: 100 }); | |
}); | |
var force = d3.layout.force() | |
.nodes(labels) | |
.links(labelLinks) | |
.gravity(0) | |
.linkDistance(0) | |
.linkStrength(8) | |
.charge(-100) | |
.size([width, height]) | |
.on("tick", tick); | |
function tick() { | |
circleNode.call(updateNode); | |
labelNode.each(function(d, i) { | |
if(i % 2 == 0) { | |
d.x = d.node.x; | |
d.y = d.node.y; | |
} else { | |
var b = this.childNodes[1].getBBox(); | |
var diffX = d.x - d.node.x, | |
diffY = d.y - d.node.y; | |
var dist = Math.sqrt(diffX * diffX + diffY * diffY); | |
var shiftX = Math.min(0, b.width * (diffX - dist) / (dist * 2)); | |
var shiftY = 5; | |
this.childNodes[1].setAttribute("transform", "translate(" + shiftX + "," + shiftY + ")"); | |
} | |
}); | |
labelNode.call(updateNode); | |
} | |
var circleNode = points.selectAll("circle") | |
.data(points) | |
.enter().append("circle") | |
.attr("class", "node") | |
.attr("r", 5) | |
.style("fill", "#555") | |
.style("stroke-width", 3); | |
var labelNode = points.selectAll("g") | |
.data(force.nodes()) | |
.enter().append("g") | |
.attr("class", "labelNode"); | |
labelNode.append("circle") | |
.attr("r", 0) | |
.style("fill", "red"); | |
labelNode.append("text") | |
.text(function(d, i) { return i % 2 == 0 ? "" : d.node.label }) | |
.style("fill", "#555") | |
var updateNode = function() { | |
this.attr("transform", function(d) { | |
return "translate(" + d.x + "," + d.y + ")"; | |
}); | |
} | |
force.start(); | |
}); | |
</script> | |
</body> | |
</html> |
type | name | price | lat | lon | url | |
---|---|---|---|---|---|---|
sushi | 이노시시 | 6 | 37.562562 | 126.926975 | http://hsong.egloos.com/3126336 | |
sushi | 스시효 | 8.8 | 37.522061 | 127.041867 | http://hsong.egloos.com/3402467 | |
sushi | 정준호 스시 | 7 | 37.487505 | 126.88886 | http://hsong.egloos.com/3383473 | |
sushi | 오가와 | 6 | 37.571923 | 126.974303 | http://hsong.egloos.com/3421468 | |
sushi | 스시시로 | 5 | 37.548968 | 126.921155 | http://hsong.egloos.com/3098726 | |
sushi | 스시조 | 25 | 37.564378 | 126.980085 | http://egloos.zum.com/redfish/v/1217180 | |
sushi | 슈치쿠 | 23 | 37.519465 | 126.939833 | http://redfish.egloos.com/1354547 | |
sushi | 스시초희 | 22 | 37.523725 | 127.035956 | http://redfish.egloos.com/1360892 | |
sushi | 스시타쿠 | 11 | 37.5272764 | 127.0352359 | http://redfish.egloos.com/1348840 | |
sushi | 아리아께 | 22 | 37.556978 | 127.005914 | http://redfish.egloos.com/1397479 | |
sushi | 스시하꼬 | 9 | 37.4897 | 126.993889 | http://redfish.egloos.com/1313557 | |
sushi | 스시타츠 | 22 | 37.518042 | 127.028216 | http://redfish.egloos.com/1393625 | |
sushi | 스시코우지 | 18 | 37.522897 | 127.039834 | http://redfish.egloos.com/1364412 | |
sushi | 코지마 | 35 | 37.5257419 | 127.0419814 | http://blog.naver.com/mardukas/220178511890 | |
sushi | 기꾸 | 4.5 | 37.517925 | 126.982401 | http://egloos.zum.com/hsong/v/3317958 | |
sushi | 스시선수 | 20 | 37.522832 | 127.036421 | http://redfish.egloos.com/1375236 | |
sushi | 스시산원 | 14 | 37.5064703 | 127.0509131 | http://redfish.egloos.com/1379800 | |
sushi | 미치루 | 8 | 37.519318 | 126.93142 | http://egloos.zum.com/redfish/v/1357195 | |
sushi | 가네끼스시 | 4 | 37.491172 | 126.9249803 | http://redfish.egloos.com/1368486 | |
sushi | 려 | 4 | 37.4817613 | 126.9514527 | http://redfish.egloos.com/1406658 |
합리적 가격으로 훌륭한 스시를 즐기고 싶을 때 : 중급 스시의 강자들
서울에서 최고급 스시를 맛보고 싶을 때