diff --git a/projects/geo-review/geoReview.js b/projects/geo-review/geoReview.js
new file mode 100644
index 0000000..38e81d1
--- /dev/null
+++ b/projects/geo-review/geoReview.js
@@ -0,0 +1,82 @@
+import InteractiveMap from './interactiveMap';
+
+export default class GeoReview {
+ constructor() {
+ this.formTemplate = document.querySelector('#addFormTemplate').innerHTML;
+ this.map = new InteractiveMap('map', this.onClick.bind(this));
+ this.map.init().then(this.onInit.bind(this));
+ }
+
+ async onInit() {
+ const coords = await this.callApi('coords');
+
+ for (const item of coords) {
+ for (let i = 0; i < item.total; i++) {
+ this.map.createPlacemark(item.coords);
+ }
+ }
+
+ document.body.addEventListener('click', this.onDocumentClick.bind(this));
+ }
+
+ async callApi(method, body = {}) {
+ const res = await fetch(`/geo-review-3/${method}`, {
+ method: 'post',
+ body: JSON.stringify(body),
+ });
+ return await res.json();
+ }
+
+ createForm(coords, reviews) {
+ const root = document.createElement('div');
+ root.innerHTML = this.formTemplate;
+ const reviewList = root.querySelector('.review-list');
+ const reviewForm = root.querySelector('[data-role=review-form]');
+ reviewForm.dataset.coords = JSON.stringify(coords);
+
+ for (const item of reviews) {
+ const div = document.createElement('div');
+ div.classList.add('review-item');
+ div.innerHTML = `
+
+ ${item.name} [${item.place}]
+
+ ${item.text}
+ `;
+ reviewList.appendChild(div);
+ }
+
+ return root;
+ }
+
+ async onClick(coords) {
+ this.map.openBalloon(coords, 'Загрузка...');
+ const list = await this.callApi('list', { coords });
+ const form = this.createForm(coords, list);
+ this.map.setBalloonContent(form.innerHTML);
+ }
+
+ async onDocumentClick(e) {
+ if (e.target.dataset.role === 'review-add') {
+ const reviewForm = document.querySelector('[data-role=review-form]');
+ const coords = JSON.parse(reviewForm.dataset.coords);
+ const data = {
+ coords,
+ review: {
+ name: document.querySelector('[data-role=review-name]').value,
+ place: document.querySelector('[data-role=review-place]').value,
+ text: document.querySelector('[data-role=review-text]').value,
+ },
+ };
+
+ try {
+ await this.callApi('add', data);
+ this.map.createPlacemark(coords);
+ this.map.closeBalloon();
+ } catch (e) {
+ const formError = document.querySelector('.form-error');
+ formError.innerText = e.message;
+ }
+ }
+ }
+}
diff --git a/projects/geo-review/index.html b/projects/geo-review/index.html
new file mode 100644
index 0000000..55d0539
--- /dev/null
+++ b/projects/geo-review/index.html
@@ -0,0 +1,57 @@
+
+
+
+
+
diff --git a/projects/geo-review/index.js b/projects/geo-review/index.js
new file mode 100644
index 0000000..a674ba9
--- /dev/null
+++ b/projects/geo-review/index.js
@@ -0,0 +1,4 @@
+import './index.html';
+import GeoReview from './geoReview';
+
+new GeoReview();
diff --git a/projects/geo-review/interactiveMap.js b/projects/geo-review/interactiveMap.js
new file mode 100644
index 0000000..8e58cb1
--- /dev/null
+++ b/projects/geo-review/interactiveMap.js
@@ -0,0 +1,67 @@
+/* global ymaps */
+
+export default class InteractiveMap {
+ constructor(mapId, onClick) {
+ this.mapId = mapId;
+ this.onClick = onClick;
+ }
+
+ async init() {
+ await this.injectYMapsScript();
+ await this.loadYMaps();
+ this.initMap();
+ }
+
+ injectYMapsScript() {
+ return new Promise((resolve) => {
+ const ymapsScript = document.createElement('script');
+ ymapsScript.src =
+ 'https://api-maps.yandex.ru/2.1/?apikey=5a4c2cfe-31f1-4007-af4e-11db22b6954b&lang=ru_RU';
+ document.body.appendChild(ymapsScript);
+ ymapsScript.addEventListener('load', resolve);
+ });
+ }
+
+ loadYMaps() {
+ return new Promise((resolve) => ymaps.ready(resolve));
+ }
+
+ initMap() {
+ this.clusterer = new ymaps.Clusterer({
+ groupByCoordinates: true,
+ clusterDisableClickZoom: true,
+ clusterOpenBalloonOnClick: false,
+ });
+ this.clusterer.events.add('click', (e) => {
+ const coords = e.get('target').geometry.getCoordinates();
+ this.onClick(coords);
+ });
+ this.map = new ymaps.Map(this.mapId, {
+ center: [55.76, 37.64],
+ zoom: 10,
+ });
+ this.map.events.add('click', (e) => this.onClick(e.get('coords')));
+ this.map.geoObjects.add(this.clusterer);
+ }
+
+ openBalloon(coords, content) {
+ this.map.balloon.open(coords, content);
+ }
+
+ setBalloonContent(content) {
+ this.map.balloon.setData(content);
+ }
+
+ closeBalloon() {
+ this.map.balloon.close();
+ }
+
+ createPlacemark(coords) {
+ const placemark = new ymaps.Placemark(coords);
+ placemark.events.add('click', (e) => {
+ const coords = e.get('target').geometry.getCoordinates();
+ this.onClick(coords);
+ });
+ this.clusterer.add(placemark);
+ }
+}
diff --git a/projects/geo-review/server/data.json b/projects/geo-review/server/data.json
new file mode 100644
index 0000000..13ba858
--- /dev/null
+++ b/projects/geo-review/server/data.json
@@ -0,0 +1 @@
+{"55.80527279361752_37.52327026367186":[{"name":"Сергей","place":"Кофемания","text":"Очень вкусно"},{"name":"Андрей","place":"Кофемания","text":"Согласен с Сергеем"}]}
\ No newline at end of file
diff --git a/projects/geo-review/server/index.js b/projects/geo-review/server/index.js
new file mode 100644
index 0000000..1e935cc
--- /dev/null
+++ b/projects/geo-review/server/index.js
@@ -0,0 +1,53 @@
+const http = require('http');
+const Storage = require('./storage');
+
+createServer();
+
+function readBody(req) {
+ return new Promise((resolve, reject) => {
+ let dataRaw = '';
+
+ req.on('data', (chunk) => (dataRaw += chunk));
+ req.on('error', reject);
+ req.on('end', () => resolve(JSON.parse(dataRaw)));
+ });
+}
+
+function end(res, data, statusCode = 200) {
+ res.statusCode = statusCode;
+ res.end(JSON.stringify(data));
+}
+
+function createServer() {
+ const storage = new Storage();
+
+ http
+ .createServer(async (req, res) => {
+ res.setHeader('content-type', 'application/json');
+
+ console.log('>', req.method, req.url);
+
+ if (req.method !== 'POST') {
+ end(res, {});
+ return;
+ }
+
+ try {
+ const body = await readBody(req);
+
+ if (req.url === '/coords') {
+ end(res, storage.getCoords());
+ } else if (req.url === '/add') {
+ storage.add(body);
+ end(res, { ok: true });
+ } else if (req.url === '/list') {
+ end(res, storage.getByCoords(body.coords));
+ } else {
+ end(res, {});
+ }
+ } catch (e) {
+ end(res, { error: { message: e.message } }, 500);
+ }
+ })
+ .listen(8181);
+}
diff --git a/projects/geo-review/server/storage.js b/projects/geo-review/server/storage.js
new file mode 100644
index 0000000..0898c33
--- /dev/null
+++ b/projects/geo-review/server/storage.js
@@ -0,0 +1,64 @@
+const fs = require('fs');
+const path = require('path');
+const dataPath = path.join(__dirname, 'data.json');
+
+class Storage {
+ constructor() {
+ if (!fs.existsSync(dataPath)) {
+ fs.writeFileSync(dataPath, '{}');
+ this.data = {};
+ } else {
+ this.data = JSON.parse(fs.readFileSync(dataPath, 'utf8'));
+ }
+ }
+
+ validateCoords(coords) {
+ if (!Array.isArray(coords) || coords.length !== 2) {
+ throw new Error('Invalid coords data');
+ }
+ }
+
+ validateReview(review) {
+ if (!review || !review.name || !review.place || !review.text) {
+ throw new Error('Invalid review data');
+ }
+ }
+
+ getIndex(coords) {
+ return `${coords[0]}_${coords[1]}`;
+ }
+
+ add(data) {
+ this.validateCoords(data.coords);
+ this.validateReview(data.review);
+ const index = this.getIndex(data.coords);
+ this.data[index] = this.data[index] || [];
+ this.data[index].push(data.review);
+ this.updateStorage();
+ }
+
+ getCoords() {
+ const coords = [];
+
+ for (const item in this.data) {
+ coords.push({
+ coords: item.split('_'),
+ total: this.data[item].length,
+ });
+ }
+
+ return coords;
+ }
+
+ getByCoords(coords) {
+ this.validateCoords(coords);
+ const index = this.getIndex(coords);
+ return this.data[index] || [];
+ }
+
+ updateStorage() {
+ fs.writeFile(dataPath, JSON.stringify(this.data), () => {});
+ }
+}
+
+module.exports = Storage;
diff --git a/projects/geo-review/settings.json b/projects/geo-review/settings.json
new file mode 100644
index 0000000..8d95040
--- /dev/null
+++ b/projects/geo-review/settings.json
@@ -0,0 +1,22 @@
+{
+ "proxy": {
+ "/geo-review-3/list": {
+ "target": "http://localhost:8181",
+ "pathRewrite": {
+ "^/geo-review-3": ""
+ }
+ },
+ "/geo-review-3/add": {
+ "target": "http://localhost:8181",
+ "pathRewrite": {
+ "^/geo-review-3": ""
+ }
+ },
+ "/geo-review-3/coords": {
+ "target": "http://localhost:8181",
+ "pathRewrite": {
+ "^/geo-review-3": ""
+ }
+ }
+ }
+}