本文由WRLD 3D赞助。 感谢您支持使SitePoint成为可能的合作伙伴。
“在城市的任何地方? 在城市的任何地方:我会告诉你最好的公共厕所。”
这是乔治·科斯坦萨杰里·塞恩菲尔德在1991年宋飞的那个情节的话; 有远见的乔治在他出世之前发明了一个应用程序-浴室取景器! 如果您是经常出差的人,父母或只是知道清洁和维护良好的空间对于某些“宁静”的重要性的人,您将了解这种想法的实用性。
因此,这一次在WRLD系列的第二篇教程中,我们将构建一个……我们称之为“设施查找器应用程序”。
窥视我们将要共同构建的内容
这不是有人第一次尝试这种想法。 在2010年,bathroomreview.ca做到了这一点(如《福布斯》中所述 )。 但是该站点不再运行。
在本轮的上一教程中,我们涵盖了相当多的基础,我们将重用其中的一些知识。 例如,我们将使用ParcelJS来构建我们的静态文件,但是我们不会再过多地讨论如何重新设置它。 我们还将突出显示建筑物,并根据用户的需求设置适当的天气条件和一天中的时间。 如果不确定这些方法的工作原理,请返回上一教程 。
在本教程中,我们将介绍以下主题:
- 创建一个简单的AdonisJS服务器端API(以缓存位置数据并处理CORS请求)。
- 如果距离用户10米之内没有缓存的位置,请从refugerestrooms.org请求公共设施数据。 我们将使用Google Distance Matrix API来计算兴趣点之间的距离。
- 突出显示具有公共设施的建筑物,并对其颜色进行着色以使其达到其等级。 绿色代表好,红色代表坏。 每个建筑物都会有一张信息卡,以提供更多信息(例如如何到达浴室)。
最后,我们将讨论如何将这种应用程序变成可行的业务。 这真的是重点吗? WRLD API提供了在真实世界地图中可视化真实世界数据的工具。 我们的工作是研究如何将该技术用于商业应用!
获取设施数据
让我们从学习如何获取设施数据以及获取数据的形式开始。我们将使用refugerestrooms.org作为数据源。 我们了解到可以通过查看文档按纬度和经度进行搜索。 实际上,我们可以发出以下请求,并查看我所在地附近的一组设施:
curl https://www.refugerestrooms.org/api/v1/restrooms/by_location.json? ↵
lat=-33.872571799999996&lng=18.6339362
我们可以指定其他一些参数(例如是否通过可访问和/或男女通用的设施进行过滤),但是这给我们提供的主要功能是一种将坐标插入搜索并获得附近位置的方法。
但是,我们不能只是从浏览器中调用它。 出于种种安全原因,不允许这样做。 也有性能原因。 如果10个人提出相同的要求,并且彼此间隔10米,该怎么办? 如果我们可以从缓存代理中更快地向它发出10个请求,那将是浪费时间。
相反,我们将建立一个简单的AdonisJS缓存API。 我们的浏览器应用程序会将请求发送到AdonisJS API,如果没有“附近”的数据; 它将向Refuge API发送请求。 我们不能花太多时间在AdonisJS的细节上,因此您必须查看文档以获取详细信息。
我也即将写一本有关它的书 ,所以这是学习它的最佳方式!
创建新的AdonisJS应用最简单的方法是安装命令行工具:
npm install --global @adonisjs/cli
这将全局启用adonis
命令行。 我们可以使用它来创建新的应用程序框架:
adonis new proxy
这需要一些时间,因为它已安装了一些东西。 完成后,您应该会看到一条消息,以运行开发服务器。 这可以通过以下方式完成:
adonis serve --dev
免费学习PHP!
全面介绍PHP和MySQL,从而实现服务器端编程的飞跃。
原价$ 11.95 您的完全免费
在浏览器中打开http://127.0.0.1:3333,您会被这种美丽所吸引:
创建迁移和模型
让我们讲一下数据库中的搜索数据。 AdonisJS支持几种不同的引擎,但是为了简单起见,我们将使用SQLite。 我们可以使用以下方法安装适当的驱动程序:
npm install --save sqlite3
接下来,让我们进行迁移和模型化。 我们只对用于搜索的坐标以及返回的JSON感兴趣。 如果坐标足够接近用户要搜索的位置,我们将重用现有的搜索响应,而不是重新请求搜索数据。
我们可以使用adonis
命令行实用程序来创建迁移和模型:
adonis make:migration search
adonis make:model search
这样就创建了几个文件。 第一个是迁移,我们可以在其中添加三个字段:
"use strict"
const Schema = use("Schema")
class SearchSchema extends Schema {
up() {
this.create("searches", table => {
table.increments()
table.string("latitude")
table.string("longitude")
table.text("response")
table.timestamps()
})
}
down() {
this.drop("searches")
}
}
module.exports = SearchSchema
这来自
proxy/database/migrations/x_search_schema.js
我们添加了latitude
, longitude
和response
字段。 尽管前两个包含浮点数据,但它们还是string
有意义,因为我们希望使用它们进行子字符串搜索。
接下来,让我们创建一个API端点:
"use strict"
const Route = use("Route")
// we don't need this anymore...
// Route.on("/").render("welcome")
Route.get("search", ({ request, response }) => {
const { latitude, longitude } = request.all()
// ...do something with latitude and longitude
})
这是来自
proxy/start/routes.js
每个AdonisJS路由都在routes.js
文件中定义。 在这里,我们注释掉了最初的“欢迎”路线,并添加了新的“搜索”路线。 闭包是用上下文对象调用的; 可以访问request
和request
对象。
我们可以期望搜索请求提供latitude
和longitude
查询字符串参数; 我们可以通过request.all
获得这些。 我们应该检查是否有任何模糊相关的坐标。 我们可以使用Search
模型来做到这一点:
const Search = use("App/Models/Search")
const searchablePoint = (raw, characters = 8) => {
const abs = Math.abs(parseFloat(raw))
return parseFloat(abs.toString().substr(0, characters))
}
Route.get("search", async ({ request, response }) => {
const { latitude, longitude } = request.all()
const searchableLatitude = searchablePoint(latitude)
const searchableLongitude = searchablePoint(longitude)
// console.log(searchableLatitude, searchableLongitude)
const searches = await Search.query()
.where("latitude", "like", `%${searchableLatitude}%`)
.where("longitude", "like", `%${searchableLongitude}%`)
.fetch()
// console.log(searches.toJSON())
response.send("done")
// ...do something with latitude and longitude
})
这是来自
proxy/start/routes.js
我们首先导入Search
模型。 这是我们创建的数据库表(使用迁移)的代码表示。 我们将使用它来查询数据库中的“附近”搜索。
在此之前,我们需要一种搜索近似坐标的方法。 searchablePoint
函数采用原始坐标字符串并创建一个绝对浮点值,并从字符串开头删除了可选的-
。 然后,它返回坐标字符串的前8
字符。 这会将-33.872527399999996
缩短为33.872527
。 然后,我们可以在SQL“ where like”子句中使用这8个字符,以返回具有相似坐标字符串的所有搜索。
AdonisJS使用async
和await
关键字效果很好。 诸如Search.query
方法Search.query
返回Search.query
,因此我们可以await
其结果的同时仍然编写100%异步代码。
我跳过了很多AdonisJS详细信息,我真的不喜欢这样做。 如果您正在为这一部分而苦苦挣扎; 在Twitter上与我交谈 ,我会为您指明正确的方向。
匹配附近位置
现在我们有了“附近”的位置,我们可以将它们的相对距离与用户站立的位置进行比较。 如果您还没有Google API密钥,请参考上一教程,了解如何获得它。 我们即将成为Google Distance Matrix服务:
https://maps.googleapis.com/maps/api/distancematrix/json? ↵
mode=walking& ↵
units=metric& ↵
origins=-33.872527399999996,18.6339164& ↵
destinations=-33.872527399999997,18.6339165& ↵
key=YOUR_API_KEY
Distance Matrix服务实际上允许多个原点,因此我们可以将您之前的所有搜索合并为冗长的原点字符串:
const reduceSearches = (acc, search) => {
const { latitude, longitude } = search
return `${acc}|${latitude},${longitude}`
}
Route.get("search", async ({ request, response }) => {
const { latitude, longitude } = request.all()
// ...get searches
const origins = searches
.toJSON()
.reduce(reduceSearches, "")
.substr(1)
// console.log(origins)
response.send("done")
// ...do something with latitude and longitude
})
这是来自
proxy/start/routes.js
我们可以将搜索结果转换为对象数组。 这很有用,因为我们可以缩小数组,将每次搜索的纬度和经度组合成一个字符串。 该字符串将以|
开头|
,因此我们需要从索引1
开始的字符串。
我是浏览器fetch
API的狂热者,所以让我们安装NodeJS polyfill:
npm install --save node-fetch-polyfill
使用此polyfill,我们可以获取Google的距离列表:
"use strict"
const fetch = use("node-fetch-polyfill")
const Env = use("Env")
const Route = use("Route")
const Search = use("App/Models/Search")
const searchablePoint = (raw, characters = 8) => {
// ...
}
const reduceSearches = (acc, search) => {
// ...
}
Route.get("search", async ({ request, response }) => {
const { latitude, longitude } = request.all()
// ...get origins
const key = Env.get("GOOGLE_KEY")
const distanceResponse = await fetch(
`https://maps.googleapis.com/maps/api/distancematrix/json? ↵
mode=walking&units=metric&origins=${origins}& ↵
destinations=${latitude},${longitude}&key=${key}`,
)
const distanceData = await distanceResponse.json()
// console.log(distanceData)
response.send("done")
// ...do something with data
})
这是来自
proxy/start/routes.js
fetch
返回一个承诺,因此我们可以await
它。 响应有一个json
方法,它将原始响应序列化为JSON数组或对象,然后组合原点坐标(所有与起点相似的东西),得到所有距离的列表。 响应对象与原点坐标的顺序相同。 随着我们的继续,这将变得有用……
AdonisJS提供了自己的
.env
文件支持。 我们可以放弃上一教程的env.example.js
和env.js
文件; 并使用已经存在的.env
和.env.example
。 我已经将GOOGLE_KEY
都添加到了两者中,你也应该添加了。 然后,我们可以使用Env.get
获取值。
我们可以检查结果以发现其中任何一个是否位于所请求坐标的10米范围内:
Route.get("search", async ({ request, response }) => {
const { latitude, longitude } = request.all()
// ...get distance data
for (let i in distanceData.rows) {
const { elements } = distanceData.rows[i]
if (typeof elements[0] === "undefined") {
continue
}
if (elements[0].status !== "OK") {
continue
}
const matches = elements[0].distance.text.match(/([0-9]+)\s+m/)
if (matches === null || parseInt(matches[1], 10) > 10) {
continue
}
response.json(JSON.parse(searchRows[i].response))
return
}
// ...cached result not found, fetch new data!
})
这是来自
proxy/start/routes.js
我们可以遍历距离行,对每个距离行进行一些检查。 如果原点坐标无效,则距离矩阵服务可能会为该行返回错误。 如果元素格式不正确(未定义或错误),则我们跳过该行。
如果存在有效的度量(以nm
的形式表示,其中n
为1 – 10); 然后我们返回该行的响应。 我们不需要新的庇护所数据。 如果很可能没有附近的坐标被缓存; 我们可以请求新数据:
Route.get("search", async ({ request, response }) => {
const { latitude, longitude } = request.all()
// ...check for cached data
const refugeResponse = await fetch(
`https://www.refugerestrooms.org/api/v1/restrooms/by_location.json? ↵
lat=${latitude}&lng=${longitude}`,
)
const refugeData = await refugeResponse.json()
await Search.create({
latitude,
longitude,
response: JSON.stringify(refugeData),
})
response.json(refugeData)
return
})
这是来自
proxy/start/routes.js
如果没有缓存的搜索,我们将请求一组新的避难所结果。 我们可以原封不动地退还它们。 但在将搜索保存到数据库之前不可以。 第一个请求应比后续请求稍慢。 我们实质上是将避难所API处理工作转移到Distance Matrix API上。 现在,我们还有一种方法来管理CORS权限。
在浏览器中获取结果
让我们开始在浏览器中使用此数据。 尝试建立一个ParcelJS构建链(或回顾我们之前做的上一个教程)。 这包括将WRLD SDK安装和加载到app.js
文件中。 它看起来应该像这样:
const Wrld = require("wrld.js")
const tester = async () => {
const response = await fetch(
"http://127.0.0.1:3333/search? ↵
latitude=-33.872527399999996&longitude=18.6339164",
)
const data = await response.json()
console.log(data)
}
tester()
这是来自
app/app.js
您应该可以将其与以下命令捆绑在一起:
parcel index.html
您的文件夹结构应类似于此:
它与上一教程中制作的文件夹结构相同。 您也可以复制所有内容,用上面看到的内容替换app.js
的内容。 tester
功能旨在证明我们尚无法从缓存代理服务器请求数据。 为此,我们需要启用AdonisJS CORS层 :
"use strict"
module.exports = {
/*
|--------------------------------------------------------------------------
| Origin
|--------------------------------------------------------------------------
|
| Set a list of origins to be allowed...
*/
origin: true,
// ...rest of the CORS settings
}
这是来自
proxy/config/cors.js
如果将origin
设置为true
,则所有CORS请求都将成功。 在生产环境中,您可能希望提供一个有条件地返回true的闭包。 这样您就可以限制谁可以对此API发出请求。
如果刷新浏览器,则打开ParcelJS所服务的URL。 您现在应该可以在控制台中看到结果:
请勿注意该警告。 只是ParcelJS Hot Module Replacement有一点时间...
从这一点开始,我们可以开始使用缓存代理服务器来查找与一组坐标最接近的设施。 让我们添加地图!
与WRLD集成
首先,将第一个教程中的env.js
和env.example.js
文件添加到app
文件夹中。 然后,我们可以使用它们再次渲染地图:
const Wrld = require("wrld.js")
const env = require("./env")
const keys = {
wrld: env.WRLD_KEY,
}
// ...tester code
window.addEventListener("load", async () => {
const map = Wrld.map("map", keys.wrld, {
center: [40.7484405, -73.98566439999999],
zoom: 15,
})
})
这是来自
app/app.js
我们回到帝国大厦。 不过,如果我们可以从更靠近用户的地方开始会更好。 并且,如果我们可以提供一种使用自定义坐标覆盖地理位置的方法。 让我们利用HTML5 Geolocation API:
window.addEventListener("load", async () => {
let map
navigator.geolocation.getCurrentPosition(
position => {
const { latitude, longitude } = position.coords
map = Wrld.map("map", keys.wrld, {
center: [latitude, longitude],
zoom: 15,
})
},
error => {
map = Wrld.map("map", keys.wrld, {
center: [40.7484405, -73.98566439999999],
zoom: 15,
})
},
)
})
这是来自
app/app.js
我们可以使用getCurrentPosition
来获取用户的最佳猜测坐标。 如果用户拒绝对地理位置数据的请求,或者发生其他问题,我们可以默认使用一组已知坐标。
没有记录的错误参数,但是我喜欢将参数放在此处以使代码更清晰。
这就是自动位置检测。 现在,如果我们想用自定义坐标覆盖它呢? 我们可以在HTML中添加一些表单输入,并使用一些Javascript作为目标:
<body>
<div id="map"></div>
<div class="controls">
<input type="text" name="latitude" />
<input type="text" name="longitude" />
<input type="button" name="apply" value="apply" />
</div>
<script src="./app.js"></script>
</body>
这是来自
app/index.html
.controls {
position: absolute;
top: 0;
right: 0;
background: rgba(255, 255, 255, 0.5);
padding: 10px;
}
这是来自
app/app.css
window.addEventListener("load", async () => {
let map
const latitudeInput = document.querySelector("[name='latitude']")
const longitudeInput = document.querySelector("[name='longitude']")
const applyButton = document.querySelector("[name='apply']")
applyButton.addEventListener("click", () => {
map.setView([latitudeInput.value, longitudeInput.value])
})
navigator.geolocation.getCurrentPosition(
position => {
const { latitude, longitude } = position.coords
latitudeInput.value = latitude
longitudeInput.value = longitude
map = Wrld.map("map", keys.wrld, {
center: [latitude, longitude],
zoom: 15,
})
},
error => {
map = Wrld.map("map", keys.wrld, {
center: [40.7484405, -73.98566439999999],
zoom: 15,
})
},
)
})
这是来自
app/app.js
我们首先获得对添加的新input
元素的引用。 单击applyButton
,我们想更新地图。 地理位置数据成功后,我们可以使用适当的纬度和经度填充这些输入。
现在,如何突出显示附近的设施建筑物?
let map
let highlightedFacilities = []
const highlightFacilities = async (latitude, longitude) => {
for (let facility of highlightedFacilities) {
facility.remove()
}
highlightedFacilities = []
const facilitiesResponse = await fetch(
`http://127.0.0.1:3333/search?latitude=${latitude}&longitude=${longitude}`,
)
const facilitiesData = await facilitiesResponse.json()
for (let facility of facilitiesData) {
// console.log(facility)
const color =
facility.upvote >= facility.downvote
? [125, 255, 125, 200]
: [255, 125, 125, 200]
const highlight = Wrld.buildings
.buildingHighlight(
Wrld.buildings
.buildingHighlightOptions()
.highlightBuildingAtLocation([
facility.latitude,
facility.longitude,
])
.color(color),
)
.addTo(map)
highlightedFacilities.push(highlight)
}
}
window.addEventListener("load", async () => {
// ...add button event
navigator.geolocation.getCurrentPosition(
position => {
const { latitude, longitude } = position.coords
// ...create map
map.on("initialstreamingcomplete", () => {
highlightFacilities(latitude, longitude)
})
},
error => {
// ...create map
map.on("initialstreamingcomplete", () => {
highlightFacilities(40.7484405, -73.98566439999999)
})
},
)
})
这是来自
app/app.js
创建地图或更改地图焦点时,可以调用highlightFacilities
函数。 这接受latitude
和longitude
,删除所有以前突出显示的建筑物,并突出显示由缓存代理搜索返回的所有建筑物。
对于具有50%或更多投票率的建筑物,我们选择绿色突出显示; 其余部分用红色突出显示。 这将使寻找更好的设施变得更加容易。
我们甚至可以使用地图的当前中心来更新替代输入,以便用户可以平移并找到靠近该地图区域的新浴室。 我们还可以使突出显示的建筑物更加清晰。 通过添加地图标记并在按下/单击时显示弹出窗口:
let map
let highlightedFacilities = []
let highlighterMarkers = []
const highlightFacilities = async (latitude, longitude) => {
for (let facility of highlightedFacilities) {
facility.remove()
}
highlightedFacilities = []
for (let marker of highlighterMarkers) {
marker.remove()
}
highlighterMarkers = []
const facilitiesResponse = await fetch(
`http://127.0.0.1:3333/search?latitude=${latitude}&longitude=${longitude}`,
)
const facilitiesData = await facilitiesResponse.json()
for (let facility of facilitiesData) {
const location = [facility.latitude, facility.longitude]
// ...add highlight color
const intersection = map.buildings.findBuildingAtLatLng(location)
let marker
if (intersection.found) {
marker = L.marker(location, {
elevation: intersection.point.alt,
title: facility.name,
}).addTo(map)
} else {
marker = L.marker(location, {
title: facility.name,
}).addTo(map)
}
if (facility.comment) {
marker.bindPopup(facility.comment).openPopup()
}
highlighterMarkers.push(marker)
}
}
window.addEventListener("load", async () => {
// ...add button event
navigator.geolocation.getCurrentPosition(
position => {
const { latitude, longitude } = position.coords
// ...create map
map.on("panend", event => {
const { lat, lng } = map.getBounds().getCenter()
latitudeInput.value = lat
longitudeInput.value = lng
})
},
error => {
// ...create map
map.on("panend", event => {
const { lat, lng } = map.getBounds().getCenter()
latitudeInput.value = lat
longitudeInput.value = lng
})
},
)
})
这是来自
app/app.js
我们可以将panend
事件添加到我们创建地图的地方。 当用户开始平移并且地图静止时触发。 我们得到可见的地图边界,并从中得到中心。
然后,在highlightFacilities
函数中,我们添加了标记和可选的弹出窗口(如果有要显示的建议。这使得发现突出显示的建筑物以及查找有关其所包含设施的任何其他信息更加容易。
增加气氛
最后,向地图视图添加一些大气效果。 首先,我们可以在缓存代理中添加“天气状况”端点:
Route.get("condition", async ({ request, response }) => {
const { latitude, longitude } = request.all()
const key = Env.get("OPENWEATHER_KEY")
const weatherResponse = await fetch(
`http://api.openweathermap.org/data/2.5/weather? ↵
lat=${latitude}&lon=${longitude}&appid=${key}`,
)
const weatherData = await weatherResponse.json()
response.json(weatherData)
})
这是来自
proxy/start/routes.js
这需要创建一个开放式天气地图帐户。 我们到达那里的API密钥需要添加到.env
和.env.example
。 然后,我们可以开始在浏览器中请求此数据。 如果该地区的天气与WRLD天气预设之一匹配; 我们可以将其应用于地图。 我们还可以使用浏览器的时间来设置一天中的时间:
const Wrld = require("wrld.js")
const env = require("./env")
const keys = {
wrld: env.WRLD_KEY,
}
let map
let highlightedFacilities = []
let highlighterMarkers = []
const highlightFacilities = async (latitude, longitude) => {
// ...highlight buildings and add markers
try {
const weatherResponse = await fetch(
`http://127.0.0.1:3333/condition? ↵
latitude=${latitude}&longitude=${longitude}`,
)
const weatherData = await weatherResponse.json()
if (weatherData.weather && weatherData.weather.length > 0) {
const condition = weatherData.weather[0].main.toLowerCase()
switch (condition) {
case "snow":
map.themes.setWeather(Wrld.themes.weather.Snowy)
break
case "few clouds":
case "scattered clouds":
case "broken clouds":
map.themes.setWeather(Wrld.themes.weather.Overcast)
break
case "mist":
map.themes.setWeather(Wrld.themes.weather.Foggy)
break
case "shower rain":
case "rain":
case "thunderstorm":
map.themes.setWeather(Wrld.themes.weather.Rainy)
break
default:
map.themes.setWeather(Wrld.themes.weather.Clear)
break
}
}
const time = new Date().getHours()
if (time > 5 && time <= 10) {
map.themes.setTime(Wrld.themes.time.Dawn)
} else if (time > 10 && time <= 16) {
map.themes.setTime(Wrld.themes.time.Day)
} else if (time > 16 && time < 21) {
map.themes.setTime(Wrld.themes.time.Dusk)
} else {
map.themes.setTime(Wrld.themes.time.Night)
}
} catch (e) {
// weather and time effects are entirely optional
// if they break, for whatever reason, they shouldn't kill the app
}
}
const latitudeInput = document.querySelector("[name='latitude']")
const longitudeInput = document.querySelector("[name='longitude']")
const applyButton = document.querySelector("[name='apply']")
const initMapEvents = async (latitude, longitude) => {
map.on("initialstreamingcomplete", () => {
highlightFacilities(latitude, longitude)
})
map.on("panend", event => {
const { lat, lng } = map.getBounds().getCenter()
latitudeInput.value = lat
longitudeInput.value = lng
})
applyButton.addEventListener("click", () => {
map.setView([latitudeInput.value, longitudeInput.value])
highlightFacilities(latitudeInput.value, longitudeInput.value)
})
}
window.addEventListener("load", async () => {
navigator.geolocation.getCurrentPosition(
position => {
// ...create map
initMapEvents(latitude, longitude)
},
error => {
// ...create map
initMapEvents(latitude, longitude)
},
)
})
这是来自
app/app.js
我利用这次机会将所有创建地图后的代码移入了可重用的initMapEvents
函数中。 另外,我将天气和时间效果添加到了highlightBuildings
函数中; 因为这是更改这些内容的最合理的地方。 如果用户输入沙漠坐标,我们不希望地图继续下雪……
不幸的是,如果没有更多的工作,一天中的时间总是相对于用户的浏览器而言的,但是我认为在本教程中这样做不是必需的。
摘要
这是一个有趣的项目。 更重要的是,您可以制作并转变为业务(希望比George的各种功绩取得更大的成功)。 也许您发现了人们需要应用程序寻找的另一种东西。 如果您拥有正确的权限和帐户限制(如OpenWeatherMap,Google,Refuge和WRLD),则可以创建任何种类的finder应用。
从我的角度来看,有两种方法可从此类应用中获利。 您可以在iOS和Android商店中出售它。 您可以将其构建到React Native应用程序中,甚至只是一个简单的Web应用程序包装器。
或者,您可以在屏幕上显示广告。 用户可以付费删除这些广告,但是您可能还需要考虑一些有关帐户登录和/或恢复购买的信息。
无论哪种方式,这都是您可以构建的实用工具。 少于200行代码。 更进一步,为每个兴趣点添加方向。 也许甚至允许用户过滤兴趣点,以便仅显示关闭3。
WRLD提供了您需要的大多数工具。
翻译自: https://www.sitepoint.com/build-seinfeld-bathroom-finder/