diff --git a/.babelrc b/.babelrc
new file mode 100644
index 0000000..d42ef4a
--- /dev/null
+++ b/.babelrc
@@ -0,0 +1,16 @@
+{
+ "presets": [
+ [
+ "@babel/env",
+ {
+ "modules": false,
+ "targets": {
+ "ie": "11"
+ }
+ }
+ ]
+ ],
+ "plugins": [
+ "@babel/transform-runtime"
+ ]
+}
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..a6690da
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,11 @@
+root = true
+
+[*.js,*.json]
+indent_style = space
+indent_size = 4
+charset = utf-8
+trim_trailing_whitespace = true
+insert_final_newline = true
+end_of_line = lf
+# editorconfig-tools is unable to ignore longs strings or urls
+max_line_length = 120
diff --git a/.eslintrc b/.eslintrc
new file mode 100644
index 0000000..55c30a5
--- /dev/null
+++ b/.eslintrc
@@ -0,0 +1,107 @@
+{
+ "parserOptions": {
+ "sourceType": "module",
+ "ecmaVersion": 7
+ },
+ "env": {
+ "node": true,
+ "browser": true,
+ "mocha": true,
+ "es6": true
+ },
+ "extends": [
+ "eslint:recommended"
+ ],
+ "rules": {
+ "no-cond-assign": "off",
+ "space-before-blocks": "error",
+ "spaced-comment": "error",
+ "curly": "error",
+ "guard-for-in": "error",
+ "no-caller": "error",
+ "no-else-return": "error",
+ "no-empty-function": "error",
+ "no-new-wrappers": "error",
+ "no-with": "error",
+ "block-spacing": "error",
+ "brace-style": [
+ "error",
+ "1tbs"
+ ],
+ "indent": [
+ "error",
+ 4,
+ {
+ "SwitchCase": 1
+ }
+ ],
+ "max-len": [
+ "error",
+ 120,
+ { "ignoreComments": true }
+ ],
+ "newline-after-var": "error",
+ "newline-before-return": "error",
+ "no-multiple-empty-lines": [
+ "error",
+ {
+ "max": 1,
+ "maxEOF": 1
+ }
+ ],
+ "no-nested-ternary": "error",
+ "no-tabs": "error",
+ "one-var-declaration-per-line": "error",
+ "quotes": [
+ "error",
+ "single"
+ ],
+ "max-statements-per-line": [
+ "error",
+ {
+ "max": 1
+ }
+ ],
+ "keyword-spacing": [
+ "error",
+ {
+ "after": true
+ }
+ ],
+ "key-spacing": [
+ "error",
+ {
+ "afterColon": true
+ }
+ ],
+ "object-curly-spacing": ["error", "always"],
+ "dot-notation": "error",
+ "no-eval": "error",
+ "no-multi-spaces": "error",
+ "yoda": "error",
+ "camelcase": [
+ "error",
+ {
+ "properties": "never"
+ }
+ ],
+ "comma-spacing": [
+ "error",
+ {
+ "after": true
+ }
+ ],
+ "consistent-this": [
+ "error",
+ "that"
+ ],
+ "lines-around-directive": [
+ "error",
+ "always"
+ ],
+ "max-nested-callbacks": [
+ "error",
+ 4
+ ]
+ }
+}
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..a59f389
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,10 @@
+.idea/
+.vscode/
+.DS_Store
+*.lock
+!yarn.lock
+*.log
+out/
+dist/
+node_modules/
+coverage/
diff --git a/.travis.yml b/.travis.yml
new file mode 100644
index 0000000..967912d
--- /dev/null
+++ b/.travis.yml
@@ -0,0 +1,10 @@
+language: node_js
+node_js:
+ - "8"
+script: "npm test -- --single-run --browsers Firefox"
+before_script:
+ - export DISPLAY=:99.0
+ - sh -e /etc/init.d/xvfb start
+cache: yarn
+addons:
+ firefox: latest
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..9022d24
--- /dev/null
+++ b/README.md
@@ -0,0 +1,10 @@
+## LoftSchool example project
+
+### Доступные команды
+
+* `npm install` - установить зависимости
+* `npm run prepare` - запустить тесты и проверить стиль кода
+* `npm run test` - запустить тесты
+* `npm run codestyle` - проверить стиль кода
+* `npm run start` - запустить встроенный сервер и следить за изменениями файлов
+* `npm run build` - собрать проект в папку `build`
diff --git a/img/balloon.svg b/img/balloon.svg
new file mode 100644
index 0000000..906180a
--- /dev/null
+++ b/img/balloon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/index.hbs b/index.hbs
new file mode 100644
index 0000000..c358c70
--- /dev/null
+++ b/index.hbs
@@ -0,0 +1,12 @@
+
+
+
+
+ {{htmlWebpackPlugin.options.title}}
+
+
+
+
+
+
\ No newline at end of file
diff --git a/index.html b/index.html
deleted file mode 100644
index 789b489..0000000
--- a/index.html
+++ /dev/null
@@ -1 +0,0 @@
-"Hello World"
diff --git a/karma.conf.js b/karma.conf.js
new file mode 100644
index 0000000..cdb8307
--- /dev/null
+++ b/karma.conf.js
@@ -0,0 +1,37 @@
+process.env.CHROME_BIN = require('puppeteer').executablePath()
+
+module.exports = function(config) {
+ config.set({
+ basePath: '',
+ frameworks: ['mocha', 'chai'],
+ files: [
+ 'test/**/*.js'
+ ],
+ preprocessors: {
+ 'test/**/*.js': ['webpack', 'sourcemap'],
+ },
+ webpack: require('./webpack.config.test'),
+ webpackMiddleware: {
+ stats: 'errors-only'
+ },
+ reporters: ['mocha', 'coverage-istanbul'],
+ coverageIstanbulReporter: {
+ reports: ['html', 'lcovonly', 'text-summary'],
+ fixWebpackSourcePaths: true
+ },
+ port: 9876,
+ browsers: ['ChromeHeadless'], // или Chrome или Firefox
+ captureTimeout: 20000,
+ singleRun: true,
+ plugins: [
+ require('karma-mocha'),
+ require('karma-chai'),
+ require('karma-webpack'),
+ require('karma-mocha-reporter'),
+ require('karma-firefox-launcher'),
+ require('karma-chrome-launcher'),
+ require('karma-coverage-istanbul-reporter'),
+ require('karma-sourcemap-loader')
+ ]
+ });
+};
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..a9dce67
--- /dev/null
+++ b/package.json
@@ -0,0 +1,55 @@
+{
+ "name": "yandex-georeview",
+ "version": "1.0.0",
+ "description": "",
+ "main": "index.js",
+ "scripts": {
+ "codestyle": "eslint src",
+ "travis": "npm run codestyle && npm test",
+ "build": "webpack --progress --colors",
+ "watch": "webpack-dev-server --progress --colors --open",
+ "start": "npm run watch",
+ "test": "karma start",
+ "cover": "istanbul cover _mocha -- test/*.js",
+ "prepare": "npm run travis"
+ },
+ "author": "",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.1.2",
+ "@babel/plugin-transform-runtime": "^7.1.0",
+ "@babel/preset-env": "^7.1.0",
+ "@babel/runtime": "^7.1.2",
+ "@fortawesome/fontawesome-free": "^5.12.1",
+ "babel-loader": "^8.0.4",
+ "clean-webpack-plugin": "^0.1.19",
+ "css-loader": "^1.0.1",
+ "file-loader": "^2.0.0",
+ "handlebars": "^4.0.12",
+ "handlebars-loader": "^1.7.0",
+ "html-webpack-plugin": "^3.2.0",
+ "mini-css-extract-plugin": "^0.4.4",
+ "normalize.css": "^8.0.1",
+ "style-loader": "^0.23.1",
+ "webpack": "^4.25.1",
+ "webpack-dev-server": "^3.1.10"
+ },
+ "devDependencies": {
+ "chai": "^4.2.0",
+ "eslint": "^5.8.0",
+ "istanbul": "^0.4.5",
+ "istanbul-instrumenter-loader": "^3.0.1",
+ "karma": "^3.1.1",
+ "karma-chai": "^0.1.0",
+ "karma-chrome-launcher": "^2.2.0",
+ "karma-coverage-istanbul-reporter": "^2.0.4",
+ "karma-firefox-launcher": "^1.1.0",
+ "karma-mocha": "^1.3.0",
+ "karma-mocha-reporter": "^2.2.5",
+ "karma-sourcemap-loader": "^0.3.7",
+ "karma-webpack": "^4.0.0-rc.2",
+ "mocha": "^5.2.0",
+ "puppeteer": "^1.10.0",
+ "webpack-cli": "^3.1.2"
+ }
+}
diff --git a/src/index.hbs b/src/index.hbs
new file mode 100644
index 0000000..c358c70
--- /dev/null
+++ b/src/index.hbs
@@ -0,0 +1,12 @@
+
+
+
+
+ {{htmlWebpackPlugin.options.title}}
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/index.js b/src/index.js
new file mode 100644
index 0000000..729bf93
--- /dev/null
+++ b/src/index.js
@@ -0,0 +1,7 @@
+require('@fortawesome/fontawesome-free/css/all.min.css');
+import './styles/balloon.css';
+import init from './js/georeview';
+import '../img/balloon.svg';
+// import init from './cluster';
+
+ymaps.ready(init);
\ No newline at end of file
diff --git a/src/js/georeview.js b/src/js/georeview.js
new file mode 100644
index 0000000..2a1ef68
--- /dev/null
+++ b/src/js/georeview.js
@@ -0,0 +1,156 @@
+const balloonTpl = require('../templates/balloon.hbs');
+const reviewsTpl = require('../templates/reviews.hbs');
+
+const init = () => {
+
+ const getCoords = (point) => {
+ return point.coords ? point.coords : point.geometry.getCoordinates();
+ };
+
+ const balloonLayout = ymaps.templateLayoutFactory.createClass(
+ '$[[options.contentLayout]]', {
+ build: function () {
+ balloonLayout.superclass.build.call(this);
+ const closeButton = document.querySelector('.btn-close');
+
+ closeButton.addEventListener('click', () => {
+ this.closeBalloon();
+ });
+
+ const addReviewBtn = document.querySelector('.add-review-btn');
+
+ addReviewBtn.addEventListener('click', e => {
+ e.preventDefault();
+ let geoObject = map.balloon.getData();
+ let reviewList = document.querySelector(".reviews-list");
+ let reviewForm = addReviewBtn.closest('form.add-review');
+ let reviewDetails = getReviewDetails(reviewForm);
+ let placeWithReview = {
+ coords: getCoords(geoObject),
+ addr: geoObject.addr,
+ review: reviewDetails
+ };
+
+ addOrUpdateLocalStorageItem(placeWithReview);
+ updateReviewList(reviewList, geoObject.addr);
+ createPlacemark(placeWithReview);
+
+ // map.balloon.close();
+ })
+ },
+ clear: function () {
+ balloonLayout.superclass.clear.call(this);
+ },
+ closeBalloon: function () {
+ this.events.fire('userclose');
+ },
+ getShape: function () {
+ let el = document.querySelector('.review-popup'),
+ result = null;
+ if (el) {
+ result = new ymaps.shape.Rectangle(
+ new ymaps.geometry.pixel.Rectangle([
+ [0, 0],
+ [el.offsetWidth, el.offsetHeight]
+ ])
+ );
+ }
+ return result;
+ }
+ });
+
+ const getReviewDetails = (reviewForm) => {
+ return {
+ author: reviewForm['author'].value,
+ place: reviewForm['place'].value,
+ reviewText: reviewForm['review-text'].value,
+ date: new Date().toLocaleDateString()
+ };
+ };
+
+ const getReviews = (address) => {
+ let currentAddressReviewsStr = localStorage.getItem(address);
+ return JSON.parse(currentAddressReviewsStr);
+ };
+
+ const addOrUpdateLocalStorageItem = (placeReview) => {
+ if (!localStorage.getItem(placeReview.addr)) {
+ let reviews = [];
+
+ reviews.push(placeReview);
+ localStorage.setItem(placeReview.addr, JSON.stringify(reviews));
+ } else {
+ let currentAddressReviews = getReviews(placeReview.addr);
+
+ currentAddressReviews.push(placeReview);
+ localStorage.setItem(placeReview.addr, JSON.stringify(currentAddressReviews));
+ }
+ };
+
+ const updateReviewList = (reviewElement, address) => {
+ let reviews = getReviews(address);
+ console.log(reviews);
+ let reviewHtml = reviewsTpl({reviews});
+ console.log(reviewHtml);
+ reviewElement.innerHTML = reviewHtml;
+ };
+
+ const reverseGeoCode = (coords) => {
+ // Определяем адрес по координатам (обратное геокодирование).
+ return ymaps.geocode(coords).then(res => {
+ let firstGeoObject = res.geoObjects.get(0);
+ return firstGeoObject.getAddressLine();
+ });
+ };
+
+ const bcl = (address) => {
+ let balloonHTML = balloonTpl(address);
+ return ymaps.templateLayoutFactory.createClass(balloonHTML);
+ };
+
+ let map = new ymaps.Map('map', {
+ center: [55.650625, 37.62708],
+ zoom: 10,
+ controlls: ['zoomControl']
+
+ }, {balloonLayout});
+
+ const openBalloon = (geoObj) => {
+ map.balloon.open(geoObj.coords, geoObj, {
+ layout: balloonLayout,
+ contentLayout: bcl(geoObj)
+ });
+ };
+
+ map.events.add('click', e => {
+ const pointCoords = e.get('coords');
+ const pointAddress = reverseGeoCode(pointCoords);
+
+ pointAddress.then(address => {
+ let geoObj = {
+ coords: pointCoords,
+ addr: address
+ };
+ openBalloon(geoObj);
+ });
+ });
+
+ const createPlacemark = (placemarkData) => {
+ let myPlacemark = new ymaps.Placemark(placemarkData.coords, {
+ geoObj: placemarkData
+ }, {
+ balloonShadow: false,
+ balloonLayout: balloonLayout,
+ balloonContentLayout: bcl(placemarkData),
+ iconLayout: 'default#image',
+ iconImageHref: './img/balloon.svg',
+ iconImageSize: [20, 30],
+ });
+
+ map.geoObjects.add(myPlacemark);
+
+ return myPlacemark;
+ };
+};
+
+export default init;
\ No newline at end of file
diff --git a/src/styles/balloon.css b/src/styles/balloon.css
new file mode 100644
index 0000000..3e4cb32
--- /dev/null
+++ b/src/styles/balloon.css
@@ -0,0 +1,185 @@
+.review-popup {
+ background-color: #ffffff;
+ border-radius: 10px;
+ width: 300px;
+ position: relative;
+ z-index: 999;
+}
+
+.map-balloon{
+ font-size: 24px;
+ cursor: pointer;
+}
+
+.popup-geo-point {
+ font-size: 14px;
+ margin-right: 7px;
+}
+
+.popup-header {
+ background-color: #ff8663;
+ padding: 15px;
+ color: #ffffff;
+ font-size: 12px;
+ border-radius: 5px 5px 0 0;
+ position: relative;
+}
+
+.reviews-list-container{
+ height: 110px;
+ overflow-x: hidden;
+ overflow-y: scroll;
+ margin-bottom: 25px;
+}
+
+.reviews-list {
+ display: flex;
+ flex-direction: column;
+}
+
+.review-author {
+ font-weight: 700;
+ margin-right: 10px;
+}
+
+.review-date {
+ margin-left: 20px;
+}
+
+ul{
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+::-webkit-scrollbar {
+ width: 5px;
+ background-color: #F5F5F5;
+}
+
+::-webkit-scrollbar-track {
+ -webkit-box-shadow: inset 0 0 6px rgba(0,0,0,0.3);
+ border-radius: 10px;
+ background-color: #F5F5F5;
+}
+
+::-webkit-scrollbar-thumb {
+ border-radius: 10px;
+ -webkit-box-shadow: inset 0 0 6px rgba(0,0,0,.3);
+ background-color: #ff8663;
+}
+
+
+.review-header {
+ display: flex;
+ align-items: center;
+ margin-bottom: 5px;
+}
+
+.review-item {
+ margin-bottom: 10px;
+}
+
+.review-item:last-child {
+ margin-bottom: 0;
+}
+
+.review-text {
+ color: #4f4f4f;
+ font-size: 15px;
+ font-weight: 400;
+ line-height: 22px;
+}
+
+
+.btn-close {
+ background: transparent;
+ border: none;
+ outline: none;
+ position: absolute;
+ right: 10px;
+ top: 8px;
+ font-size: 16px;
+ color: #ffffff;
+ cursor: pointer;
+ padding: 5px;
+}
+
+.reviews {
+ color: #9f9f9f;
+ padding: 15px 15px 0 15px;
+ margin-bottom: 15px;
+ height: 150px;
+}
+
+.add-review-form::before {
+ content: "";
+ margin-bottom: 10px;
+ display: block;
+ width: 100%;
+ height: 1px;
+ background-color: #f0f0ff;
+}
+
+.delimiter{
+ border-top: 1px solid #f0f0f0;
+}
+
+.add-review-header{
+ color: #ff8663;
+ font-size: 16px;
+ text-align: left;
+ font-weight: 500;
+ text-transform: uppercase;
+}
+
+html, body, #map {
+ width: 100%;
+ height: 100%;
+ padding: 0;
+ margin: 0;
+}
+
+.add-review-form{
+ padding: 0 0px 15px 5px;
+}
+
+
+
+form {
+ display: flex;
+ flex-direction: column;
+}
+
+input, #review-textarea {
+ font-size: 15px;
+ margin-bottom: 16px;
+ margin-left: 5px;
+ margin-right: 10px;
+ padding: 10px 10px;
+ border: none;
+}
+
+input:focus, #review-textarea:focus {
+ color: #9f9f9f;
+ outline: none;
+ border: 1px solid #29B0D9;
+ border-radius: 8px;
+}
+
+#review-textarea{
+ height: 100px;
+}
+
+.add-review-btn {
+ float: right;
+ color: #ffffff;
+ right: 15px;
+ bottom: 10px;
+ padding: 10px;
+ font-size: 14px;
+ border: 1px solid #ff8663;
+ border-radius: 10px;
+ background-color: #ff8663;
+ outline: none;
+}
diff --git a/src/templates/balloon.hbs b/src/templates/balloon.hbs
new file mode 100644
index 0000000..a4b038a
--- /dev/null
+++ b/src/templates/balloon.hbs
@@ -0,0 +1,36 @@
+
\ No newline at end of file
diff --git a/src/templates/reviews.hbs b/src/templates/reviews.hbs
new file mode 100644
index 0000000..9e3f64b
--- /dev/null
+++ b/src/templates/reviews.hbs
@@ -0,0 +1,12 @@
+{{#each reviews}}
+
+
+ {{review.reviewText}}
+
+{{else}}
+ Отзывов пока нет
+{{/each}}
\ No newline at end of file
diff --git a/webpack.config.js b/webpack.config.js
new file mode 100644
index 0000000..54a08ec
--- /dev/null
+++ b/webpack.config.js
@@ -0,0 +1,74 @@
+const HtmlPlugin = require('html-webpack-plugin');
+const CleanWebpackPlugin = require('clean-webpack-plugin');
+const MiniCssExtractPlugin = require('mini-css-extract-plugin');
+const rules = require('./webpack.config.rules');
+const fs = require('fs');
+const path = require('path');
+
+const root = path.resolve('src');
+const files = fs.readdirSync(root)
+ .reduce((all, current) => {
+ const ext = path.extname(current);
+ const name = path.basename(current, ext);
+ const absPath = path.join(root, current);
+
+ if (!all.hasOwnProperty(ext)) {
+ all[ext] = [];
+ }
+
+ all[ext].push({ name, absPath });
+
+ return all;
+ }, { '.js': [], '.hbs': [] });
+const entries = files['.js'].reduce((all, { name, absPath }) => {
+ all[name] = absPath;
+
+ return all;
+}, {});
+const html = files['.hbs']
+ .filter(file => entries.hasOwnProperty(file.name))
+ .map((file) => {
+ return new HtmlPlugin({
+ title: file.name,
+ template: file.absPath,
+ filename: `${file.name}.html`,
+ chunks: [file.name]
+ });
+ });
+
+if (!html.length || !files['.hbs'].find(file => file.name === 'index')) {
+ html.push(new HtmlPlugin({
+ title: 'index',
+ template: 'index.hbs',
+ chunks: ['index']
+ }));
+}
+
+module.exports = {
+ entry: entries,
+ output: {
+ filename: '[name].[hash].js',
+ path: path.resolve('dist')
+ },
+ mode: 'development',
+ devtool: 'source-map',
+ module: {
+ rules: [
+ ...rules,
+ {
+ test: /\.css$/,
+ use: [
+ MiniCssExtractPlugin.loader,
+ 'css-loader'
+ ]
+ }
+ ]
+ },
+ plugins: [
+ new MiniCssExtractPlugin({
+ filename: '[name].css',
+ }),
+ ...html,
+ new CleanWebpackPlugin(['dist'])
+ ]
+};
\ No newline at end of file
diff --git a/webpack.config.rules.js b/webpack.config.rules.js
new file mode 100644
index 0000000..9a68e28
--- /dev/null
+++ b/webpack.config.rules.js
@@ -0,0 +1,20 @@
+module.exports = [
+ {
+ test: /\.js$/,
+ exclude: /node_modules/,
+ loader: 'babel-loader',
+ options: { cacheDirectory: true }
+ },
+ {
+ test: /\.hbs/,
+ loader: 'handlebars-loader'
+ },
+ {
+ test: /\.(jpe?g|png|gif|svg|eot|ttf|woff|woff2)$/i,
+ loader: 'file-loader',
+ options: {
+ name: '[hash:8].[ext]',
+ outputPath: 'reosurces'
+ }
+ }
+];
\ No newline at end of file
diff --git a/webpack.config.test.js b/webpack.config.test.js
new file mode 100644
index 0000000..9dd9124
--- /dev/null
+++ b/webpack.config.test.js
@@ -0,0 +1,24 @@
+const path = require('path');
+const rules = require('./webpack.config.rules');
+
+module.exports = {
+ mode: 'development',
+ devtool: 'inline-source-map',
+ module: {
+ rules: [
+ ...rules,
+ {
+ test: /\.js$/,
+ enforce: 'post',
+ include: [path.resolve('src/')],
+ loader: 'istanbul-instrumenter-loader',
+ options: { esModules: true }
+ },
+ {
+ test: /\.css$/,
+ include: path.resolve('src/'),
+ use: ['style-loader', 'css-loader']
+ }
+ ]
+ }
+};