");
11 | $(document).trigger('config', mock.config);
12 | expect($("#name").html()).toEqual(mock.config.project.name);
13 | expect($("#desc").html()).toEqual(mock.config.project.description);
14 | });
15 |
16 | it('updates content of meta fields', function() {
17 | setFixtures("
");
18 | $(document).trigger('config', mock.config);
19 | expect($("meta[name=description]").attr('content')).toEqual(
20 | $("
").html(mock.config.project.description).text());
21 | });
22 | });
23 | });
24 | });
25 |
--------------------------------------------------------------------------------
/test/spec/ui/info_spec.js:
--------------------------------------------------------------------------------
1 | define(['infotemplates', 'jquery'], function(templates, $) {
2 | 'use strict';
3 | describeComponent('ui/info', function() {
4 | beforeEach(function() {
5 | setupComponent('
');
6 | });
7 |
8 | describe('on selectFeature', function() {
9 | it('creates a popup with the properties of the feature and config', function() {
10 | var config = {properties: 'config properties'},
11 | feature = {properties: 'feature properties'};
12 | spyOn(templates, 'popup');
13 | $(document).trigger('config', config);
14 | $(document).trigger('selectFeature', feature);
15 | expect(templates.popup).toHaveBeenCalledWith(
16 | config.properties, feature.properties);
17 | });
18 | });
19 |
20 | describe('on close click', function() {
21 | it('hides the popup', function() {
22 | spyOn(this.$node, 'hide');
23 | this.$node.find('.close').click();
24 | expect(this.$node.hide).toHaveBeenCalledWith();
25 | });
26 | });
27 | });
28 | });
29 |
--------------------------------------------------------------------------------
/script/copyfiles.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 | 'use strict';
3 | var path = require('path');
4 | var fs = require('fs');
5 | var glob = require('glob');
6 | var mkdirp = require('mkdirp');
7 | var input = [
8 | 'index.html',
9 | 'data.geojson',
10 | 'config.json',
11 | 'styles/properties.css',
12 | 'img/logo.png'
13 | ];
14 | var inGlobs = [
15 | '*.md',
16 | 'lib/leaflet/images/marker-*'
17 | ];
18 | var outDir = 'dist';
19 |
20 | function move(infile, outpath) {
21 | fs.createReadStream(infile).pipe(fs.createWriteStream(path.join(outpath, infile)));
22 | }
23 |
24 | function moveGlob (inGlob, outpath) {
25 | glob(inGlob, function (err, files) {
26 | if (err) {
27 | console.log(err);
28 | }
29 | files.forEach(function (file) {
30 | move(file, outpath);
31 | });
32 | });
33 | }
34 |
35 | mkdirp.sync(path.join(outDir,'lib/leaflet/images'));
36 | mkdirp.sync(path.join(outDir,'styles'));
37 | mkdirp.sync(path.join(outDir,'img'));
38 | input.forEach(function (file) {
39 | move(file, outDir);
40 | });
41 | inGlobs.forEach(function (file) {
42 | moveGlob(file, outDir);
43 | });
44 |
--------------------------------------------------------------------------------
/license.md:
--------------------------------------------------------------------------------
1 | Copyright (c) 2013 Code For Boston
2 | =====
3 |
4 | Permission is hereby granted, free of charge, to any person obtaining a copy
5 | of this software and associated documentation files (the "Software"), to deal
6 | in the Software without restriction, including without limitation the rights
7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8 | copies of the Software, and to permit persons to whom the Software is
9 | furnished to do so, subject to the following conditions:
10 |
11 | The above copyright notice and this permission notice shall be included in
12 | all copies or substantial portions of the Software.
13 |
14 | **THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
20 | THE SOFTWARE.**
21 |
--------------------------------------------------------------------------------
/data.geojson:
--------------------------------------------------------------------------------
1 | {
2 | "type": "FeatureCollection",
3 | "features": [
4 | {
5 | "type": "Feature",
6 | "geometry": {
7 | "type": "Point",
8 | "coordinates": [
9 | -84.4934789,
10 | 38.082537
11 | ]
12 | },
13 | "properties": {
14 | "address": "1351 Newtown Pike Building 5",
15 | "organization_name": "Bluegrass.org Pride Program",
16 | "city": "Lexington",
17 | "web_url": "http://www.firstchurchuu.org/outreach.html#glbt",
18 | "phone_numbers": [
19 | "859-425-1210"
20 | ],
21 | "contact_names": [],
22 | "contact_emails": [
23 | "test@gmail.com"
24 | ],
25 | "facility_type": [
26 | "out_patient"
27 | ],
28 | "service_class_level_1": [
29 | "Para-professional Support Services"
30 | ],
31 | "service_class_level_2": [
32 | "Para-professional Counseling, Therapy and Support"
33 | ],
34 | "target_populations": [
35 | "LGBTQ"
36 | ],
37 | "age_range": "",
38 | "additional_notes": ""
39 | }
40 | }
41 | ]
42 | }
43 |
--------------------------------------------------------------------------------
/src/ui/loading.js:
--------------------------------------------------------------------------------
1 | define(function(require) {
2 | 'use strict';
3 | var flight = require('flight');
4 | require('bootstrap');
5 |
6 | return flight.component(function loading() {
7 | this.attributes({
8 | contentSelector: 'h4',
9 | loadingText: 'Loading...'
10 | });
11 |
12 | var showCount = 0;
13 |
14 | this.showLoading = function() {
15 | this.show(this.attr.loadingText);
16 | };
17 |
18 | this.show = function(content) {
19 | showCount = showCount + 1;
20 | if (showCount === 1) { // first show
21 | this.select('contentSelector').text(content);
22 | this.$node.modal({
23 | keyboard: false
24 | });
25 | }
26 | };
27 |
28 | this.hide = function() {
29 | showCount = showCount - 1;
30 | if (showCount === 0) {
31 | this.$node.modal('hide');
32 | }
33 | };
34 |
35 | this.after('initialize', function() {
36 | showCount = 0;
37 | this.on(document, 'mapStarted', this.showLoading);
38 | this.on(document, 'mapFinished', this.hide);
39 |
40 | this.on(document, 'listStarted', this.showLoading);
41 | this.on(document, 'listFinished', this.hide);
42 | });
43 | });
44 | });
45 |
--------------------------------------------------------------------------------
/lib/leaflet/L.Control.Locate.css:
--------------------------------------------------------------------------------
1 | /* Compatible with Leaflet 0.7 */
2 |
3 | .leaflet-touch .leaflet-bar-part-single {
4 | -webkit-border-radius: 7px 7px 7px 7px;
5 | border-radius: 7px 7px 7px 7px;
6 | border-bottom: none;
7 | }
8 |
9 | .leaflet-control-locate a {
10 | background-image: url(images/locate.png);
11 | background-size:90px 30px;
12 | background-position: -2px -2px;
13 | }
14 |
15 | .leaflet-retina .leaflet-control-locate a {
16 | background-image: url(images/locate@2x.png);
17 | }
18 |
19 | .leaflet-touch .leaflet-control-locate a {
20 | background-image: url(images/locate_touch.png);
21 | }
22 |
23 | .leaflet-control-locate.requesting a {
24 | background-size:12px 12px;
25 | background-image: url(images/spinner.gif);
26 | background-position: 50% 50%;
27 | }
28 |
29 | .leaflet-retina .leaflet-control-locate.requesting a {
30 | background-image: url(images/spinner@2x.gif);
31 | }
32 |
33 | .leaflet-control-locate.active a {
34 | background-position: -32px -2px;
35 | }
36 |
37 | .leaflet-control-locate.active.following a {
38 | background-position: -62px -2px;
39 | }
40 |
41 | .leaflet-touch .leaflet-control-locate {
42 | box-shadow: none;
43 | border: 2px solid rgba(0,0,0,0.2);
44 | background-clip: padding-box;
45 | }
46 |
--------------------------------------------------------------------------------
/test/runner.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 | var tests = [];
3 | for (var file in window.__karma__.files) {
4 | if (window.__karma__.files.hasOwnProperty(file)) {
5 | if (/_spec\.js$/.test(file)) {
6 | tests.push(file);
7 | }
8 | }
9 | }
10 |
11 | require.config({
12 | baseUrl: '/base/src/',
13 | paths: {
14 | 'test': '../test',
15 | 'jquery': '../lib/jquery-1.11.1.min',
16 | 'leaflet': '../lib/leaflet/leaflet-src',
17 | 'L.Control.Locate': '../lib/leaflet/L.Control.Locate',
18 | 'leaflet.markercluster': '../lib/leaflet.markercluster/leaflet.markercluster-src',
19 | 'handlebars': '../lib/handlebars',
20 | 'lodash': '../lib/lodash.min',
21 | 'flight': '../lib/flight.min',
22 | 'd3': '../lib/d3.min',
23 | 'Tabletop': '../lib/tabletop',
24 | 'bootstrap': '../lib/bootstrap.min',
25 | 'text': '../lib/text'
26 | },
27 | shim: {
28 | 'handlebars': {
29 | exports: 'Handlebars'
30 | },
31 | 'underscore': {
32 | exports: '_'
33 | },
34 | 'flight': {
35 | exports: 'flight'
36 | },
37 | leaflet: {
38 | exports: 'L'
39 | },
40 | bootstrap: ['jquery'],
41 | 'L.Control.Locate': ['leaflet'],
42 | 'leaflet.markercluster': ['leaflet']
43 | },
44 |
45 | deps: tests,
46 | callback: window.__karma__.start
47 | });
48 |
--------------------------------------------------------------------------------
/src/ui/select_county.js:
--------------------------------------------------------------------------------
1 | define(function(require, exports, module) {
2 | 'use strict';
3 | var flight = require('flight');
4 | var $ = require('jquery');
5 | var _ = require('lodash');
6 |
7 | module.exports = flight.component(function selectCounty() {
8 | this.loadData = function(ev, data) {
9 | var counties = _.map(data.features, function(feature) {
10 | if (_.isString(feature.properties.county)) {
11 | return feature.properties.county.trim();
12 | }
13 | });
14 | counties = _.uniq(counties);
15 | counties = _.compact(counties);
16 | counties = _.sortBy(counties);
17 |
18 | counties.forEach(function(county) {
19 | this.$node.append($("
").html(county).attr('value', county));
20 | }.bind(this));
21 | };
22 |
23 | this.onCountySelected = function onCountySelected() {
24 | var county = $(this.$node).val();
25 | var selected = [];
26 | if (county) {
27 | selected.push(county);
28 | }
29 | $(document).trigger('uiFilterFacet', {
30 | facet: "county",
31 | selected: selected
32 | });
33 | };
34 |
35 | this.after('initialize', function() {
36 | this.on(document, 'data', this.loadData);
37 | this.on('change', this.onCountySelected);
38 | });
39 | });
40 | });
41 |
--------------------------------------------------------------------------------
/src/data/typeahead.js:
--------------------------------------------------------------------------------
1 | define(function(require) {
2 | 'use strict';
3 | var flight = require('flight');
4 | var _ = require('lodash');
5 | var Fuse = require('fuse');
6 |
7 | return flight.component(function typeahead() {
8 | this.configureSearch = function(ev, config) {
9 | if (config.search && config.search.full_text) {
10 | this.options = config.search.full_text;
11 | } else {
12 | this.teardown();
13 | }
14 | };
15 |
16 | this.createFuse = function(ev, data) {
17 | var options = _.clone(this.options);
18 | // convert the keys to something that works with the features from the
19 | // GeoJSON
20 | options.keys = _.map(options.keys, function(key) {
21 | return 'properties.' + key;
22 | });
23 | this.fuse = new Fuse(data.features,
24 | options);
25 | };
26 |
27 | this.updateSearch = function(ev, options) {
28 | if (!this.options || !this.fuse) {
29 | return;
30 | }
31 | var results = this.fuse.search(options.query);
32 | this.trigger(document, 'dataTypeaheadResults', {results: results});
33 | };
34 |
35 | this.after('initialize', function() {
36 | this.on(document, 'config', this.configureSearch);
37 | this.on(document, 'data', this.createFuse);
38 | this.on(document, 'uiInProgressSearch', this.updateSearch);
39 | });
40 | });
41 | });
42 |
--------------------------------------------------------------------------------
/src/ui/info.js:
--------------------------------------------------------------------------------
1 | define(function(require, exports, module) {
2 | 'use strict';
3 | var flight = require('flight');
4 | var $ = require('jquery');
5 | var templates = require('infotemplates');
6 | module.exports = flight.component(function info() {
7 | this.defaultAttrs({
8 | "contentClass": "content",
9 | "closeSelector": ".close"
10 | });
11 |
12 | this.configureInfo = function(ev, config) {
13 | this.infoConfig = config.properties;
14 | };
15 |
16 | this.update = function(ev, feature) {
17 | if (!feature) {
18 | return;
19 | }
20 | this.attr.currentFeature = feature;
21 | var popup = templates.popup(this.infoConfig,
22 | feature.properties);
23 | var content = this.$node.find("div." + this.attr.contentClass);
24 | if (!content.length) {
25 | content = $("
").addClass(this.attr.contentClass).
26 | appendTo(this.$node);
27 | }
28 | content.html(popup);
29 | this.$node.show();
30 | };
31 |
32 | this.hide = function() {
33 | this.$node.hide();
34 | this.trigger(document, 'deselectFeature', this.attr.currentFeature);
35 | this.attr.currentFeature = null;
36 | };
37 |
38 | this.after('initialize', function() {
39 | this.on(document, 'config', this.configureInfo);
40 | this.on(document, 'selectFeature', this.update);
41 | this.on('click', {
42 | closeSelector: this.hide
43 | });
44 | });
45 | });
46 | });
47 |
--------------------------------------------------------------------------------
/lib/leaflet.markercluster/MarkerCluster.Default.css:
--------------------------------------------------------------------------------
1 | .marker-cluster-small {
2 | background-color: rgba(181, 226, 140, 0.6);
3 | }
4 | .marker-cluster-small div {
5 | background-color: rgba(110, 204, 57, 0.6);
6 | }
7 |
8 | .marker-cluster-medium {
9 | background-color: rgba(241, 211, 87, 0.6);
10 | }
11 | .marker-cluster-medium div {
12 | background-color: rgba(240, 194, 12, 0.6);
13 | }
14 |
15 | .marker-cluster-large {
16 | background-color: rgba(253, 156, 115, 0.6);
17 | }
18 | .marker-cluster-large div {
19 | background-color: rgba(241, 128, 23, 0.6);
20 | }
21 |
22 | /* IE 6-8 fallback colors */
23 | .leaflet-oldie .marker-cluster-small {
24 | background-color: rgb(181, 226, 140);
25 | }
26 | .leaflet-oldie .marker-cluster-small div {
27 | background-color: rgb(110, 204, 57);
28 | }
29 |
30 | .leaflet-oldie .marker-cluster-medium {
31 | background-color: rgb(241, 211, 87);
32 | }
33 | .leaflet-oldie .marker-cluster-medium div {
34 | background-color: rgb(240, 194, 12);
35 | }
36 |
37 | .leaflet-oldie .marker-cluster-large {
38 | background-color: rgb(253, 156, 115);
39 | }
40 | .leaflet-oldie .marker-cluster-large div {
41 | background-color: rgb(241, 128, 23);
42 | }
43 |
44 | .marker-cluster {
45 | background-clip: padding-box;
46 | border-radius: 20px;
47 | }
48 | .marker-cluster div {
49 | width: 30px;
50 | height: 30px;
51 | margin-left: 5px;
52 | margin-top: 5px;
53 |
54 | text-align: center;
55 | border-radius: 15px;
56 | font: 12px "Helvetica Neue", Arial, Helvetica, sans-serif;
57 | }
58 | .marker-cluster span {
59 | line-height: 30px;
60 | }
--------------------------------------------------------------------------------
/src/timed_with_object.js:
--------------------------------------------------------------------------------
1 | define(function(require) {
2 | 'use strict';
3 | var $ = require('jquery');
4 |
5 | var chunkLength = 50,
6 | tickLength = 25;
7 |
8 | function nextTick(f) {
9 | window.setTimeout(f, tickLength);
10 | }
11 |
12 | return function timedWithObject(array, handler, object, newThis) {
13 | // Given an array and a handler function, calls the handler function with
14 | // each element of the array, along with arbitrary object, returning a
15 | // promise. The value returned from the previous call to the handler is
16 | // used as the object for the next call, and the final object is used to
17 | // resolve the returned promise. If processing takes longer than 50ms,
18 | // further chunks will be done in other interations of the event loop to
19 | // avoid blocking the UI thread. Based on Nicholas C. Zakas'
20 | // timedChunk().
21 | var deferred = $.Deferred();
22 | if (array.length === 0) {
23 | deferred.resolve(object);
24 | return deferred.promise();
25 | }
26 |
27 | array = array.slice();
28 |
29 | nextTick(function internalLoop() {
30 | var start = +new Date(),
31 | offset;
32 |
33 | do {
34 | object = handler.call(newThis, array.shift(), object);
35 | offset = +new Date() - start;
36 | } while (array.length > 0 && offset < chunkLength);
37 |
38 | if (array.length > 0) {
39 | nextTick(internalLoop);
40 | } else {
41 | deferred.resolve(object);
42 | }
43 |
44 | });
45 |
46 | return deferred.promise();
47 | };
48 | });
49 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "finda",
3 | "version": "0.0.0",
4 | "description": "Generic 'find-a' app for geographic datasets",
5 | "directories": {
6 | "test": "test"
7 | },
8 | "devDependencies": {
9 | "karma": "~0.13.22",
10 | "jshint": "~2.4.4",
11 | "karma-requirejs": "~0.2.6",
12 | "karma-jasmine": "~0.1.5",
13 | "karma-cli": "0.0.3",
14 | "karma-phantomjs-launcher": "~0.1.2",
15 | "almond": "^0.2.9",
16 | "clean-css": "^2.1.6",
17 | "requirejs": "^2.1.11",
18 | "rimraf": "^2.2.6",
19 | "replace": "^0.2.9",
20 | "glob": "^3.2.9",
21 | "mkdirp": "^0.3.5",
22 | "copyfiles": "0.0.1"
23 | },
24 | "scripts": {
25 | "start": "http-server",
26 | "test": "jshint src && karma start --single-run --browsers PhantomJS",
27 | "test-server": "jshint src && karma start --browsers PhantomJS",
28 | "test-client": "jshint src && karma run",
29 | "build": "npm run clean && npm run copy && npm run cssmin && npm run requirejs && npm run processhtml && npm run index",
30 | "clean": "rimraf dist",
31 | "copy": "copyfiles index.html data.geojson config.json *.md styles/properties.css img/* lib/leaflet/images/marker-* dist",
32 | "cssmin": "cleancss --s1 -o dist/styles/style.css styles/style.css",
33 | "requirejs": "r.js -o script/build.js",
34 | "index": "replace -s 'data-main=\"(src/script.js)\" src=\"lib/require.js\"' 'src=\"$1\"' dist/index.html"
35 | },
36 | "repository": {
37 | "type": "git",
38 | "url": "https://github.com/codeforboston/finda.git"
39 | },
40 | "license": "MIT",
41 | "private": true
42 | }
43 |
--------------------------------------------------------------------------------
/test/spec/ui/loading_spec.js:
--------------------------------------------------------------------------------
1 | define(['jquery'], function($) {
2 | 'use strict';
3 | describeComponent('ui/loading', function() {
4 | beforeEach(function() {
5 | setupComponent('
', {
6 | contentSelector: 'h4'
7 | });
8 | spyOn(this.$node, 'modal');
9 | });
10 |
11 | describe('loading', function() {
12 | it("displays a loading message on loading events", function() {
13 | $(document).trigger('mapStarted', {});
14 | expect(this.component.select('contentSelector').text()).toEqual(
15 | this.component.attr.loadingText);
16 | });
17 | });
18 |
19 | describe("the modal", function() {
20 | beforeEach(function() {
21 | $(document).trigger('mapStarted', {});
22 | });
23 | it("is shown loading events", function() {
24 | expect(this.$node.modal).toHaveBeenCalledWith({
25 | keyboard: false
26 | });
27 | });
28 | it("isn't re-shown a second time", function() {
29 | $(document).trigger('listStarted', {});
30 | expect(this.$node.modal.callCount).toEqual(1);
31 | });
32 | it("doesn't hide the modal if things are still loading", function() {
33 | $(document).trigger('listStarted', {});
34 | $(document).trigger('mapFinished', {});
35 | expect(this.$node.modal).not.toHaveBeenCalledWith('hide');
36 | });
37 | it("hides when all finish events is triggered", function() {
38 | $(document).trigger('listStarted', {});
39 | $(document).trigger('mapFinished', {});
40 | $(document).trigger('listFinished', {});
41 | expect(this.$node.modal).toHaveBeenCalledWith('hide');
42 | });
43 | });
44 | });
45 | });
46 |
--------------------------------------------------------------------------------
/styles/navbar_header.css:
--------------------------------------------------------------------------------
1 |
2 |
3 | #ghl-navbar {
4 | height:50px !important;
5 | }
6 |
7 | #ghl-navbar ul.nav {
8 | font-size: 12px;
9 | }
10 |
11 | .navbar-header .navbar-nav>li, .navbar-header .navbar-nav{
12 | float: left !important;
13 | }
14 |
15 |
16 |
17 | .navbar-header .navbar-right{
18 | float: right !important;
19 | }
20 |
21 | .navbar-header {
22 | width:100%;
23 | height:55px;
24 | }
25 |
26 | .navbar-header .navbar-nav>li {
27 | text-align: center;
28 | position: relative;
29 | }
30 |
31 | .navbar-header .header-nav-icon {
32 | width:18px;opacity: 0.7;position:absolute;top:-7px;left:50%;margin-left:-10px;
33 | }
34 |
35 | .navbar-header .navbar-nav>li>a {
36 | /*line-height: 24px;*/
37 | padding-top:13px;
38 | padding-bottom:7px;
39 | padding-left:10px;
40 | padding-right:10px;
41 | min-width:45px;
42 | }
43 |
44 | .navbar-header .home-nav-icon {
45 | width:20px;
46 | top:-6px;
47 | }
48 |
49 | @media (min-width: 768px) {
50 | .navbar-header .navbar-nav {
51 | margin-top:7px;
52 | }
53 | .navbar-header .navbar-nav>li>a {
54 | min-width:60px;
55 | }
56 | }
57 |
58 | @media (max-width: 498px) {
59 | .navbar-header .home-nav-entry { display:none; }
60 | .navbar-header .brand_image { margin-left:-10px; margin-left:-10px; }
61 | .navbar-header { margin-left:0px; !important; margin-right:0px; !important; }
62 | .navbar-header .navbar-nav>li>a { min-width:30px; padding-right:5px; padding-right:5px; }
63 | .navbar-header .info-nav-icon { margin-left:-8px; }
64 | }
--------------------------------------------------------------------------------
/src/ui/search.js:
--------------------------------------------------------------------------------
1 | define(function(require, exports, module) {
2 | 'use strict';
3 | var flight = require('flight');
4 | var _ = require('lodash');
5 |
6 | module.exports = flight.component(function search() {
7 | this.defaultAttrs({
8 | searchSelector: 'input',
9 | searchResultsSelector: '#search-results',
10 | resultTemplate: '
{{ organization_name }} ({{ address }})'
11 | });
12 |
13 | this.configureSearch = function(ev, config) {
14 | if (config.search && config.search.geosearch) {
15 | this.$node.show();
16 | } else {
17 | this.$node.hide();
18 | }
19 | };
20 |
21 | this.inProgressSearch = _.debounce(function(ev) {
22 | ev.preventDefault();
23 | if (ev.keyCode === 13) { // Enter
24 | return;
25 | }
26 | var query = this.select('searchSelector').val();
27 | this.trigger(document, 'uiInProgressSearch', {
28 | query: query
29 | });
30 | }, 100);
31 |
32 | this.search = function(ev) {
33 | ev.preventDefault();
34 | var address = this.select('searchSelector').val();
35 | if (address) {
36 | this.trigger(document, 'uiSearch', {query: address});
37 | }
38 | this.select('searchResultsSelector').trigger('uiHideSearchResults');
39 | };
40 |
41 | this.onSearchResult = function(ev, result) {
42 | this.select('searchSelector').attr('placeholder',
43 | result.name).val('');
44 | };
45 |
46 | this.after('initialize', function() {
47 | this.on(this.attr.searchSelector, 'keydown', this.inProgressSearch);
48 | this.on('submit', this.search);
49 | this.on(document, 'config', this.configureSearch);
50 | this.on(document, 'dataSearchResult', this.onSearchResult);
51 | });
52 | });
53 | });
54 |
--------------------------------------------------------------------------------
/test/spec/data/search_spec.js:
--------------------------------------------------------------------------------
1 | define(['test/mock', 'jquery'], function(mock, $) {
2 | 'use strict';
3 | describeComponent('data/search', function() {
4 | beforeEach(function() {
5 | setupComponent();
6 | spyOn($, 'getJSON');
7 | spyOnEvent(document, 'dataSearchResult');
8 | });
9 |
10 | describe('on config', function() {
11 | beforeEach(function() {
12 | this.component.trigger('config', mock.config);
13 | });
14 | it('records the bounds of the search', function() {
15 | expect(this.component.maxBounds).toEqual(mock.config.map.maxBounds);
16 | });
17 | });
18 |
19 | describe('on uiSearch', function() {
20 | beforeEach(function() {
21 | this.component.trigger('config', mock.config);
22 | });
23 | it('searches for the given query', function() {
24 | this.component.trigger('uiSearch', {
25 | query: 'search query'
26 | });
27 | expect($.getJSON).toHaveBeenCalledWith(
28 | this.component.attr.searchUrl,
29 | {
30 | format: 'json',
31 | addressdetails: 1,
32 | q: 'search query',
33 | viewbox: '39.2,-78,44.5,-65'
34 | },
35 | jasmine.any(Function)
36 | );
37 | });
38 | });
39 |
40 | describe("#onSearchResult", function() {
41 | it("does nothing if there's no result", function() {
42 | this.component.searchResults([]);
43 | expect('dataSearchResult').not.toHaveBeenTriggered();
44 | });
45 |
46 | it("triggers dataSearchResult if there's a result", function() {
47 | this.component.searchResults([mock.openSearchResult]);
48 | expect('dataSearchResult').toHaveBeenTriggeredOnAndWith(
49 | document,
50 | mock.parsedSearchResult);
51 | });
52 | });
53 | });
54 | });
55 |
--------------------------------------------------------------------------------
/src/data/search.js:
--------------------------------------------------------------------------------
1 | define(function(require) {
2 | 'use strict';
3 | var flight = require('flight');
4 | var _ = require('lodash');
5 | var $ = require('jquery');
6 |
7 | return flight.component(function search() {
8 | this.defaultAttrs({
9 | searchUrl: '//nominatim.openstreetmap.org/search'
10 | });
11 | this.configureSearch = function(ev, config) {
12 | if (config.search && config.search.geosearch) {
13 | this.maxBounds = config.map.maxBounds;
14 | } else {
15 | this.teardown();
16 | }
17 | };
18 |
19 | this.onSearch = function(ev, options) {
20 | ev.preventDefault();
21 | var parameters = {
22 | format: "json",
23 | addressdetails: 1,
24 | q: options.query
25 | };
26 | if (this.maxBounds) {
27 | parameters.viewbox = [
28 | this.maxBounds[0][0], this.maxBounds[0][1],
29 | this.maxBounds[1][0], this.maxBounds[1][1]
30 | ].join(',');
31 | }
32 | $.getJSON(this.attr.searchUrl,
33 | parameters,
34 | this.searchResults.bind(this));
35 | };
36 |
37 | this.searchResults = function(results) {
38 | if (results.length) {
39 | var location = results[0],
40 | displayName = _.compact([location.address.road,
41 | location.address.city,
42 | location.address.state
43 | ]).join(', ');
44 | this.trigger('dataSearchResult', {
45 | name: displayName,
46 | lat: location.lat,
47 | lng: location.lon
48 | });
49 | }
50 | };
51 |
52 | this.after('initialize', function() {
53 | this.on(document, 'config', this.configureSearch);
54 | this.on(document, 'uiSearch', this.onSearch);
55 | });
56 | });
57 | });
58 |
--------------------------------------------------------------------------------
/src/script.js:
--------------------------------------------------------------------------------
1 | require.config({
2 | baseUrl: 'src/',
3 | paths: {
4 | 'jquery': '../lib/jquery-1.11.1.min',
5 | 'bootstrap': '../lib/bootstrap.min',
6 | 'leaflet': '../lib/leaflet/leaflet',
7 | 'L.Control.Locate': '../lib/leaflet/L.Control.Locate',
8 | 'leaflet.markercluster': '../lib/leaflet.markercluster/leaflet.markercluster',
9 | 'handlebars': '../lib/handlebars',
10 | 'lodash': '../lib/lodash.min',
11 | 'flight': '../lib/flight.min',
12 | 'fuse': '../lib/fuse.min',
13 | 'Tabletop': '../lib/tabletop',
14 | 'd3': '../lib/d3.min',
15 | 'StateMachine': '../lib/state-machine',
16 | 'text': '../lib/text'
17 | },
18 | shim: {
19 | 'handlebars': {
20 | exports: 'Handlebars'
21 | },
22 | 'lodash': {
23 | exports: '_'
24 | },
25 | 'flight': {
26 | exports: 'flight'
27 | },
28 | 'bootstrap': ['jquery'],
29 | leaflet: {
30 | exports: 'L'
31 | },
32 | 'L.Control.Locate': ['leaflet'],
33 | 'leaflet.markercluster': ['leaflet']
34 | }
35 | });
36 |
37 | define(function(require) {
38 | 'use strict';
39 | require('bootstrap');
40 | // attach components to the DOM
41 | require('ui/map').attachTo('#map');
42 | require('ui/search').attachTo('#search');
43 | require('ui/search_results').attachTo('#search-results');
44 | require('ui/info').attachTo('#info');
45 | require('ui/list').attachTo('#list');
46 | require('ui/tabs').attachTo('#finda-tabs');
47 | require('ui/facet').attachTo('#facets');
48 | require('ui/loading').attachTo('#loading');
49 | require('ui/filtering').attachTo('#message');
50 | require('ui/feedback_widget').attachTo('#feedback-modal');
51 | require('ui/back-to-top').attachTo('#back-to-top');
52 | require('ui/select_county').attachTo('#select_county');
53 | require('ui/scroll').attachTo(document);
54 | require('ui/project').attachTo(document);
55 | require('data/facet').attachTo(document);
56 | require('data/search').attachTo(document);
57 | require('data/typeahead').attachTo(document);
58 | require('data/geojson').attachTo(document);
59 | require('data/config').attachTo(document);
60 | });
61 |
--------------------------------------------------------------------------------
/test/spec/ui/search_spec.js:
--------------------------------------------------------------------------------
1 | define(['test/mock', 'jquery'], function(mock, $) {
2 | 'use strict';
3 | describeComponent('ui/search', function() {
4 | beforeEach(function() {
5 | setupComponent('
',
6 | {searchSelector: 'input',
7 | mapSelector: 'div'});
8 | spyOnEvent('div', 'panTo');
9 | spyOnEvent(document, 'uiSearch');
10 | spyOn($, 'getJSON');
11 | this.config = {search: {geosearch: true},
12 | map: {maxBounds: 'maxBounds'}};
13 | });
14 |
15 | describe('configuration sets up local values', function() {
16 | beforeEach(function() {
17 | $(document).trigger('config', this.config);
18 | });
19 | it('hides the widget if it is not requested', function() {
20 | this.config.search.geosearch = false;
21 | $(document).trigger('config', this.config);
22 | expect(this.$node).not.toBeVisible();
23 | });
24 | });
25 |
26 | describe('form submission', function() {
27 | beforeEach(function() {
28 | this.component.maxBounds = [[1, 2], [3, 4]];
29 | spyOnEvent(this.component.node, 'uiSearch');
30 | });
31 | it('does nothing if the input is empty', function() {
32 | this.$node.find('input').submit();
33 | expect('uiSearch').not.toHaveBeenTriggered();
34 | });
35 | it('emits a uiSearch event with the search query', function() {
36 | this.$node.find('input').val('address').submit();
37 | expect('uiSearch').toHaveBeenTriggeredOnAndWith(document,
38 | {query: 'address'});
39 | });
40 | });
41 |
42 | describe('dataSearchResult', function() {
43 | beforeEach(function() {
44 | $(document).trigger('dataSearchResult', mock.parsedSearchResult);
45 | });
46 | it('sets the placeholder display to the city', function() {
47 | var input = this.component.select('searchSelector');
48 | expect(input.attr('placeholder')).toEqual(
49 | mock.parsedSearchResult.name);
50 | expect(input.val()).toEqual('');
51 | });
52 | });
53 | });
54 | });
55 |
--------------------------------------------------------------------------------
/src/templates/extraResources.html:
--------------------------------------------------------------------------------
1 |
Additional Resources
2 |
3 |
SAMHSA's National Helpline
4 |
5 |
SAMHSA website
6 |
SAMHSA Buprenorphine Treatment Physician Locator
7 |
1-800-662-HELP (4357)
8 |
1-800-662-9832 (Español)
9 |
TTY: 1-800-487-4889
10 |
11 |
Also known as, the Treatment Referral Routing Service, this Helpline provides 24-hour free and confidential treatment referral and information about mental and/or substance use disorders, prevention, and recovery in English and Spanish.
12 |
13 |
Suicide Prevention Lifeline
14 |
15 |
1-800-273-TALK (8255)
16 |
TTY: 1-800-799-4889
17 |
18 |
Suicide Prevention Lifelife website
19 |
20 |
This is a 24-hour, toll-free, confidential suicide prevention hotline available to anyone in suicidal crisis or emotional distress. Your call is routed to the nearest crisis center in the national network of more than 150 crisis centers.
21 |
22 |
Community Mental Health Centers’ Crisis Lines
23 |
24 |
No matter where you live in Kentucky, there is a community mental health center to serve you. The community mental health centers cover all 120 counties. The link below will provide you with the crisis line for each county in Kentucky. However, if you are experiencing an emergency situation, please call 911 .
25 |
26 |
Community Mental Health Centers Crisis Lines website
27 |
28 |
Alcoholics Anonymous
29 |
30 |
1-800-467-8019
31 |
32 |
Alcoholics Anonymous website
33 |
34 |
Narcotics Anonymous
35 |
36 |
1-859-253-4673 (Lexington)
37 |
38 |
Narcotics Anonymous website
39 |
40 |
41 |
--------------------------------------------------------------------------------
/test/spec/infotemplates_spec.js:
--------------------------------------------------------------------------------
1 | define(
2 | ['infotemplates', 'jquery', 'test/mock'],
3 | function(templates, $, mock) {
4 | 'use strict';
5 | describe('infotemplates', function() {
6 | var config = mock.config.properties,
7 | feature = mock.data.features[0].properties;
8 | describe('#popup', function() {
9 | var rendered = templates.popup(config, feature),
10 | $rendered = $(rendered);
11 | it('urls are rendered as links', function () {
12 | var link = $rendered.find('.feature-web_url a');
13 | expect(link.length).toEqual(1);
14 | expect(link.attr('href')).toEqual(feature.web_url);
15 | expect(link.text()).toEqual(config[3].title);
16 | });
17 | it('directions are rendered as links to Google Maps', function() {
18 | var $directions = $rendered.find('.feature-address a');
19 | expect($directions.text()).toEqual(config[2].title);
20 | expect($directions.attr('href')).toMatch('maps.google.com');
21 | expect($directions.attr('href')).toMatch('q=' + encodeURIComponent(
22 | feature.address.replace('\n', ' ')));
23 | });
24 | it('images are rendered as images', function() {
25 | var image = $rendered.find('.feature-image img');
26 | expect(image.attr('src')).toEqual(
27 | feature.image);
28 | });
29 | it('titles are rendered as h4s', function () {
30 | var title = $rendered.find('.feature-contact_names h4');
31 | expect(title.length).toEqual(1);
32 | expect(title.text()).toEqual(config[4].title);
33 | });
34 | it('lists are rendered as unordered lists', function() {
35 | var services_offered = $rendered.find(
36 | '.feature-services_offered ul');
37 | expect(services_offered.length).toEqual(1);
38 | expect(services_offered.find('li').length).toEqual(2);
39 | });
40 | it('plain text is rendered as-is, with \n ->
', function () {
41 | var address = $rendered.find('.feature-address');
42 | expect(address.length).toEqual(2);
43 | expect(address.html()).toEqual(
44 | feature.address.replace(/\n/g, '
'));
45 | });
46 | it('empty attributes are not rendered', function() {
47 | var additional_notes = $rendered.find('.feature-additional_notes');
48 | expect(additional_notes.length).toEqual(0);
49 | });
50 | });
51 | });
52 | });
53 |
--------------------------------------------------------------------------------
/test/spec/data/geojson_spec.js:
--------------------------------------------------------------------------------
1 | define(function() {
2 | 'use strict';
3 | describeComponent('data/geojson', function() {
4 | beforeEach(function() {
5 | setupComponent();
6 | });
7 |
8 | describe("#processData", function() {
9 | it("gives an ID to features", function() {
10 | var data = {
11 | features: [{}]
12 | };
13 | var processed = this.component.processData(data);
14 | expect(processed.features[0].id).not.toBeUndefined();
15 | });
16 |
17 | it("keeps an existing ID if present", function() {
18 | var data = {
19 | features: [{
20 | id: 1
21 | }]
22 | };
23 | var processed = this.component.processData(data);
24 | expect(processed.features[0].id).toEqual('1');
25 | });
26 | });
27 |
28 | describe('ETL CSV to GeoJSON', function() {
29 | it('groups facet values into facets', function() {
30 |
31 | var csvRow = {
32 | organization_name: 'My org',
33 | outpatient_offered: "1",
34 | residential_offered: "1"
35 | };
36 |
37 | var facetValues = {
38 | outpatient_offered: "facility_type",
39 | residential_offered: "facility_type",
40 | }
41 |
42 | var properties = {
43 | "organization_name": "My org",
44 | "facility_type": [
45 | "outpatient_offered",
46 | "residential_offered"
47 | ]
48 | };
49 |
50 | var processed = this.component.csvRowToProperties(csvRow, facetValues);
51 | expect(processed.organization_name).toEqual(properties.organization_name);
52 | expect(processed.facility_type).toEqual(properties.facility_type);
53 | });
54 |
55 | it('it only includes the search values an org offers', function() {
56 |
57 | var csvRow = {
58 | organization_name: 'My org',
59 | outpatient_offered: "1",
60 | residential_offered: "0"
61 | };
62 |
63 | var facetValues = {
64 | outpatient_offered: "facility_type",
65 | residential_offered: "facility_type",
66 | }
67 |
68 | var properties = {
69 | "organization_name": "My org",
70 | "facility_type": [
71 | "outpatient_offered"
72 | ]
73 | };
74 |
75 | var processed = this.component.csvRowToProperties(csvRow, facetValues);
76 | expect(processed.facility_type).toEqual(properties.facility_type);
77 | });
78 | });
79 | });
80 | });
81 |
--------------------------------------------------------------------------------
/karma.conf.js:
--------------------------------------------------------------------------------
1 | // Karma configuration
2 | // Generated on Sat Jan 25 2014 18:43:51 GMT-0500 (EST)
3 |
4 | module.exports = function(config) {
5 | config.set({
6 |
7 | // base path, that will be used to resolve files and exclude
8 | basePath: '',
9 |
10 |
11 | // frameworks to use
12 | frameworks: ['jasmine'],
13 |
14 |
15 | // list of files / patterns to load in the browser
16 | files: [
17 | 'lib/es5-shim.min.js',
18 | 'lib/es5-sham.min.js',
19 | 'lib/jquery-1.11.1.min.js',
20 |
21 | 'test/lib/jasmine-jquery.js',
22 | 'test/lib/jasmine-flight.js',
23 |
24 | // hack to load RequireJS after the shim libs
25 | 'lib/require.js',
26 | 'node_modules/karma-requirejs/lib/adapter.js',
27 |
28 | {pattern: 'src/**/*.js', included: false},
29 | {pattern: 'src/templates/*.html', included: false},
30 | {pattern: 'lib/**/*.js', included: false},
31 | {pattern: 'test/spec/**/*_spec.js', included: false},
32 | {pattern: 'test/mock.js', included: false},
33 | {pattern: 'lib/leaflet/images/*', included: false},
34 | 'test/runner.js'
35 | ],
36 |
37 |
38 | // list of files to exclude
39 | exclude: [
40 | 'src/script.js'
41 | ],
42 |
43 |
44 | // test results reporter to use
45 | // possible values: 'dots', 'progress', 'junit', 'growl', 'coverage'
46 | reporters: ['progress'],
47 |
48 |
49 | // web server port
50 | port: 9876,
51 |
52 |
53 | // enable / disable colors in the output (reporters and logs)
54 | colors: true,
55 |
56 |
57 | // level of logging
58 | // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG
59 | logLevel: config.LOG_INFO,
60 |
61 |
62 | // enable / disable watching file and executing tests whenever any file changes
63 | autoWatch: true,
64 |
65 |
66 | // Start these browsers, currently available:
67 | // - Chrome
68 | // - ChromeCanary
69 | // - Firefox
70 | // - Opera (has to be installed with `npm install karma-opera-launcher`)
71 | // - Safari (only Mac; has to be installed with `npm install karma-safari-launcher`)
72 | // - PhantomJS
73 | // - IE (only Windows; has to be installed with `npm install karma-ie-launcher`)
74 | browsers: ['PhantomJS'],
75 |
76 |
77 | // If browser does not capture in given timeout [ms], kill it
78 | captureTimeout: 60000,
79 |
80 |
81 | // Continuous Integration mode
82 | // if true, it capture browsers, run tests and exit
83 | singleRun: false
84 | });
85 | };
86 |
--------------------------------------------------------------------------------
/lib/es5-sham.min.js:
--------------------------------------------------------------------------------
1 | (function(d){"function"==typeof define?define(d):"function"==typeof YUI?YUI.add("es5-sham",d):d()})(function(){function d(a){try{return Object.defineProperty(a,"sentinel",{}),"sentinel"in a}catch(c){}}Object.getPrototypeOf||(Object.getPrototypeOf=function(a){return a.__proto__||(a.constructor?a.constructor.prototype:prototypeOfObject)});Object.getOwnPropertyDescriptor||(Object.getOwnPropertyDescriptor=function(a,c){if(typeof a!="object"&&typeof a!="function"||a===null)throw new TypeError("Object.getOwnPropertyDescriptor called on a non-object: "+
2 | a);if(owns(a,c)){var b={enumerable:true,configurable:true};if(supportsAccessors){var d=a.__proto__;a.__proto__=prototypeOfObject;var f=lookupGetter(a,c),e=lookupSetter(a,c);a.__proto__=d;if(f||e){if(f)b.get=f;if(e)b.set=e;return b}}b.value=a[c];return b}});Object.getOwnPropertyNames||(Object.getOwnPropertyNames=function(a){return Object.keys(a)});Object.create||(Object.create=function(a,c){var b;if(a===null)b={__proto__:null};else{if(typeof a!="object")throw new TypeError("typeof prototype["+typeof a+
3 | "] != 'object'");b=function(){};b.prototype=a;b=new b;b.__proto__=a}c!==void 0&&Object.defineProperties(b,c);return b});if(Object.defineProperty){var g=d({}),h="undefined"==typeof document||d(document.createElement("div"));if(!g||!h)var e=Object.defineProperty}if(!Object.defineProperty||e)Object.defineProperty=function(a,c,b){if(typeof a!="object"&&typeof a!="function"||a===null)throw new TypeError("Object.defineProperty called on non-object: "+a);if(typeof b!="object"&&typeof b!="function"||b===
4 | null)throw new TypeError("Property description must be an object: "+b);if(e)try{return e.call(Object,a,c,b)}catch(d){}if(owns(b,"value"))if(supportsAccessors&&(lookupGetter(a,c)||lookupSetter(a,c))){var f=a.__proto__;a.__proto__=prototypeOfObject;delete a[c];a[c]=b.value;a.__proto__=f}else a[c]=b.value;else{if(!supportsAccessors)throw new TypeError("getters & setters can not be defined on this javascript engine");owns(b,"get")&&defineGetter(a,c,b.get);owns(b,"set")&&defineSetter(a,c,b.set)}return a};
5 | Object.defineProperties||(Object.defineProperties=function(a,c){for(var b in c)owns(c,b)&&b!="__proto__"&&Object.defineProperty(a,b,c[b]);return a});Object.seal||(Object.seal=function(a){return a});Object.freeze||(Object.freeze=function(a){return a});try{Object.freeze(function(){})}catch(j){var i=Object.freeze;Object.freeze=function(a){return typeof a=="function"?a:i(a)}}Object.preventExtensions||(Object.preventExtensions=function(a){return a});Object.isSealed||(Object.isSealed=function(){return false});
6 | Object.isFrozen||(Object.isFrozen=function(){return false});Object.isExtensible||(Object.isExtensible=function(a){if(Object(a)!==a)throw new TypeError;for(var c="";owns(a,c);)c=c+"?";a[c]=true;var b=owns(a,c);delete a[c];return b})});
7 |
--------------------------------------------------------------------------------
/src/ui/feedback_widget.js:
--------------------------------------------------------------------------------
1 | define(function(require, exports, module) {
2 | 'use strict';
3 | var flight = require('flight');
4 | var $ = require('jquery');
5 |
6 | module.exports = flight.component(function analytics() {
7 | this.feedback = function() {
8 | return {
9 | feedback: this.$node.find('#feedback-text').val(),
10 | email: this.$node.find('#feedback-email').val()
11 | };
12 | };
13 |
14 | this.submitBtn = function() {
15 | return this.$node.find('.js-submit');
16 | };
17 |
18 | this.resetSubmitBtn = function() {
19 | this.submitBtn().html('Send');
20 | };
21 |
22 | this.addFeedbackToTagManager = function() {
23 | // track feedback in Google Tag Manager as a backup
24 | window.dataLayer.push({
25 | 'eventLabel': this.feedback()
26 | });
27 | };
28 |
29 | this.handleSubmission = function(e) {
30 | e.preventDefault();
31 | this.submitBtn().html('Sending...');
32 | this.addFeedbackToTagManager();
33 |
34 | // see google-apps-feedback-script.js
35 | $.ajax({
36 | url: 'https://script.google.com/macros/s/AKfycbzziKocYO7ZmbLvRaSI_OEFSHTVwnCFrTfQT-OzoqAVQvpg1ZE/exec',
37 | type: 'POST',
38 | data: this.feedback(),
39 | success: this.success.bind(this),
40 | error: this.error.bind(this)
41 | });
42 | };
43 |
44 | this.errorAfterSuccessfulMobileSubmit = function(response) {
45 | this.success();
46 | throw('Error function called after successful mobile submission. Response: ' +
47 | JSON.stringify(response) +
48 | ', feedback ' + this.feedback());
49 | };
50 |
51 | this.error = function(response) {
52 | if (response.status === 0 && response.responseText === "") {
53 | return this.errorAfterSuccessfulMobileSubmit(response);
54 | }
55 | this.resetSubmitBtn();
56 | var error = this.$node.find('.js-error');
57 | var body = this.feedback() + " \n\n(Error details: " + JSON.stringify(response) + ")";
58 | error.find('.js-error-email').prop('href', 'mailto:gethelplex@lexingtonky.gov?subject=[GetHelpLex feedback]&body=' + body);
59 | error.show();
60 | };
61 |
62 | this.success = function() {
63 | this.resetSubmitBtn();
64 | this.$node.find('.js-thank-you').show();
65 | setTimeout(function() {
66 | this.$node.modal('hide');
67 | this.$node.find('.js-thank-you').hide();
68 | }.bind(this), 2000);
69 | };
70 |
71 | this.handleCancel = function() {
72 | this.$node.modal('hide');
73 | };
74 |
75 | this.after('initialize', function() {
76 | this.on('#feedback-form', 'submit', this.handleSubmission);
77 | this.on('.btn-cancel', 'click', this.handleCancel);
78 | });
79 | });
80 | });
81 |
--------------------------------------------------------------------------------
/test/spec/ui/list_spec.js:
--------------------------------------------------------------------------------
1 | define(
2 | ['test/mock', 'infotemplates', 'jquery'],
3 | function(mock, templates, $) {
4 | 'use strict';
5 | describeComponent('ui/list', function() {
6 | beforeEach(function() {
7 | setupComponent();
8 | });
9 |
10 | describe('on config', function() {
11 | it("calls teardown if there's no list configuration", function() {
12 | spyOn(this.component, 'teardown').andCallThrough();
13 | $(document).trigger('config', {});
14 | expect(this.component.teardown).toHaveBeenCalledWith();
15 | });
16 | });
17 |
18 | describe('on data', function() {
19 | beforeEach(function() {
20 | $(document).trigger('config', mock.config);
21 | $(document).trigger('data', mock.data);
22 | waits(25);
23 | });
24 |
25 | it('creates a list item for each feature', function() {
26 | expect(this.$node.find('li').length).toEqual(
27 | mock.data.features.length);
28 | });
29 |
30 | it('renders the list config into the list items', function() {
31 | var $li = this.$node.find('li:eq(0)');
32 | var feature = $li.data('feature');
33 | expect($li.html()).toEqual(
34 | templates.popup(mock.config.list, feature.properties, feature.id));
35 | });
36 |
37 | it('sorts the list items by their text', function() {
38 | var texts = this.$node.find('li').map(function() {
39 | return this.innerText;
40 | }).get();
41 | var sorted = texts.slice();
42 | sorted = sorted.sort();
43 | expect(texts).toEqual(sorted);
44 | });
45 | });
46 |
47 | describe('on item click', function() {
48 | beforeEach(function() {
49 | $(document).trigger('config', mock.config);
50 | $(document).trigger('data', mock.data);
51 | waits(25);
52 | });
53 | it('triggers a selectFeature event', function() {
54 | var spy = spyOnEvent(document, 'selectFeature');
55 | var $li = this.$node.find('li:eq(1)');
56 |
57 | $li.click();
58 | expect(spy).toHaveBeenTriggeredOnAndWith(document,
59 | $li.data('feature'));
60 | });
61 | });
62 |
63 | describe('on selectFeature', function() {
64 | beforeEach(function() {
65 | $(document).trigger('config', mock.config);
66 | $(document).trigger('data', mock.data);
67 | waits(25);
68 | });
69 |
70 | it('scrolls to the selected feature', function() {
71 | var $li = this.$node.find('li:eq(1)');
72 | var feature = $li.data('feature');
73 |
74 | $(document).trigger('selectFeature', feature);
75 | expect(window.location.hash).toBe('#' + feature.id);
76 | });
77 | });
78 | });
79 | });
80 |
--------------------------------------------------------------------------------
/src/ui/search_results.js:
--------------------------------------------------------------------------------
1 | define(function(require, exports, module) {
2 | 'use strict';
3 | var flight = require('flight');
4 | var $ = require('jquery');
5 | var _ = require('lodash');
6 | var Handlebars = require('handlebars');
7 |
8 | module.exports = flight.component(function searchResults() {
9 | this.defaultAttrs({
10 | searchSelector: "#search",
11 | helpSelector: ".help",
12 | helpTemplate: "Press
Enter to find '
{{query}} ' on the map.",
13 | suggestedSelector: ".suggested",
14 | resultContainerSelector: "ul",
15 | resultSelector: "li",
16 | resultTemplate: '
{{ organization_name }} ({{ address }})'
17 | });
18 |
19 | this.showHelp = function(ev, options) {
20 | if (options.query) {
21 | this.select('helpSelector').html(
22 | Handlebars.compile(this.attr.helpTemplate)(options));
23 | this.trigger('uiShowSearchResults');
24 | } else {
25 | this.trigger('uiHideSearchResults');
26 | }
27 | };
28 |
29 | this.searchResults = function(ev, options) {
30 | var results = options.results,
31 | resultTemplate = Handlebars.compile(this.attr.resultTemplate),
32 | $container = this.select('resultContainerSelector');
33 |
34 | $container.empty();
35 | if (results.length) {
36 | _.each(results.slice(0, 5), function(result) {
37 | var html = resultTemplate(result.properties);
38 | $(html).data('result', result).appendTo($container);
39 | }, this);
40 | this.select('suggestedSelector').show();
41 | $container.show();
42 | this.trigger('uiShowSearchResults');
43 | } else {
44 | this.select('suggestedSelector').hide();
45 | $container.hide();
46 | }
47 | };
48 |
49 | this.selectedResult = function(ev) {
50 | ev.preventDefault();
51 | var $target = $(ev.target).closest(this.attr.resultSelector),
52 | display = $target.text(),
53 | result = $target.data('result');
54 |
55 | this.trigger('uiHideSearchResults');
56 | this.trigger(this.attr.searchSelector,
57 | 'uiShowingSearchResult',
58 | {display: display});
59 | this.trigger(document, 'selectFeature', result);
60 | };
61 |
62 | this.showSearchResults = function(ev) {
63 | ev.preventDefault();
64 | this.$node.show();
65 | };
66 |
67 | this.hideSearchResults = function(ev) {
68 | ev.preventDefault();
69 | this.$node.hide();
70 | };
71 |
72 | this.after('initialize', function() {
73 | this.on(document, 'dataTypeaheadResults', this.searchResults);
74 | this.on(document, 'uiInProgressSearch', this.showHelp);
75 | this.on('uiShowSearchResults', this.showSearchResults);
76 | this.on('uiHideSearchResults', this.hideSearchResults);
77 | this.on('click', {
78 | resultSelector: this.selectedResult
79 | });
80 | });
81 | });
82 | });
83 |
--------------------------------------------------------------------------------
/src/templates/twelveStepPrograms.html:
--------------------------------------------------------------------------------
1 |
TWELVE STEP PROGRAMS
2 |
4 |
Alcoholics Anonymous
5 |
To find a meeting anywhere in Kentucky:
6 | http://www.aa.org
7 |
8 |
Bluegrass Intergroup
9 |
Lexington, KY
10 | Phone/FAX: 859-225-1212
11 | www.bluegrassintergroup.org
12 |
13 |
Bowling Green Central Office
14 |
Bowling Green, KY
15 | Main: 270-782-5267
16 |
17 |
Northern Kentucky Central Office
18 |
Covington, KY
19 | Main: 859-491-7181
20 | www.home.fuse.net/nkyaa
21 |
22 |
Western Kentucky Intergroup
23 |
Gilbertsville, KY
24 | Toll Free: 800-606-6047
25 | Main: 270-362-7141
26 |
27 |
Greater Louisville Intergroup, Inc.
28 |
Louisville, KY
29 | Main: 502-582-1849
30 | www.louisvilleaa.org
31 |
32 |
Intergroup 17
33 |
Owensboro, KY
34 | Main: 270-683-0371
35 |
36 |
Narcotics Anonymous
37 |
https://www.na.org/
38 | To find a meeting anywhere in Kentucky-Kentuckiana Region:
39 | http://www.krscna.org
40 |
41 |
Pennyrile Area (Paducah & Hopkinsville)
42 |
http://www.na-pana.org
43 |
44 |
Southern Zonal Forum
45 |
http://www.szfna.org
46 |
47 |
Across the River Area (Henderson)
48 |
Phone: 877-642-5831
49 | http://www.atrana.org/
50 |
51 |
South Central Kentucky Area (Bowling Green, Glasgow, Scottsville, Franklin)
52 |
Phone: 866-901-2849
53 | http://sckana.net
54 |
55 |
Louisville Area
56 |
Phone: 502-499-4423
57 | http://nalouisville.org/
58 |
59 |
Grassroots Area (Campton, Inez, Salyersville)
60 |
Phone: 855-319-8869
61 | http://www.grassrootsna.org
62 |
63 |
Bluegrass-Appalachian Region (Central Kentucky)
64 |
Phone: 859-253-4673
65 | http://www.barcna.com
66 |
67 |
Kentucky Survivors Area (Lexington)
68 |
Phone: 859-253-4673
69 | http://www.kentuckysurvivors.com
70 |
--------------------------------------------------------------------------------
/google-apps-feedback-script.js:
--------------------------------------------------------------------------------
1 | // Copy of the Google Apps Script that you install in Google sheet to make it a feedback endpoint
2 | // based on:
3 | // https://mashe.hawksey.info/2014/07/google-sheets-as-a-database-insert-with-apps-script-using-postget-methods-with-ajax-example/
4 |
5 | // 1. Enter sheet name where data is to be written below
6 | var SHEET_NAME = "Sheet1";
7 |
8 | // 2. Run > setup
9 | //
10 | // 3. Publish > Deploy as web app
11 | // - enter Project Version name and click 'Save New Version'
12 | // - set security level and enable service (most likely execute as 'me' and access 'anyone, even anonymously)
13 | //
14 | // 4. Copy the 'Current web app URL' and post this in your form/script action
15 | //
16 | // 5. Insert column names on your destination sheet matching the parameter names of the data you are passing in (exactly matching case)
17 |
18 | var SCRIPT_PROP = PropertiesService.getScriptProperties(); // new property service
19 |
20 | // If you don't want to expose either GET or POST methods you can comment out the appropriate function
21 | function doGet(e){
22 | return handleResponse(e);
23 | }
24 |
25 | function doPost(e){
26 | return handleResponse(e);
27 | }
28 |
29 | function handleResponse(e) {
30 | // shortly after my original solution Google announced the LockService[1]
31 | // this prevents concurrent access overwritting data
32 | // [1] http://googleappsdeveloper.blogspot.co.uk/2011/10/concurrency-and-google-apps-script.html
33 | // we want a public lock, one that locks for all invocations
34 | var lock = LockService.getPublicLock();
35 | lock.waitLock(30000); // wait 30 seconds before conceding defeat.
36 |
37 | try {
38 | var msg = e.parameters.feedback;
39 | if (e.parameters.email) { msg += ', email: ' + e.parameters.email; }
40 |
41 | MailApp.sendEmail("foo@bar.com", "[GetHelpLex feedback]", msg);
42 |
43 | // next set where we write the data - you could write to multiple/alternate destinations
44 | var doc = SpreadsheetApp.openById(SCRIPT_PROP.getProperty("key"));
45 | var sheet = doc.getSheetByName(SHEET_NAME);
46 |
47 | // we'll assume header is in row 1 but you can override with header_row in GET/POST data
48 | var headRow = e.parameter.header_row || 1;
49 | var headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
50 | var nextRow = sheet.getLastRow()+1; // get next row
51 | var row = [];
52 | // loop through the header columns
53 | for (i in headers){
54 | if (headers[i] == "Timestamp"){ // special case if you include a 'Timestamp' column
55 | row.push(new Date());
56 | } else { // else use header name to get data
57 | row.push(e.parameter[headers[i]]);
58 | }
59 | }
60 | // more efficient to set values as [][] array than individually
61 | sheet.getRange(nextRow, 1, 1, row.length).setValues([row]);
62 | // return json success results
63 | return ContentService
64 | .createTextOutput(JSON.stringify({"result":"success", "row": nextRow}))
65 | .setMimeType(ContentService.MimeType.JSON);
66 | } catch(e){
67 | // if error return this
68 | return ContentService
69 | .createTextOutput(JSON.stringify({"result":"error", "error": e}))
70 | .setMimeType(ContentService.MimeType.JSON);
71 | } finally { //release lock
72 | lock.releaseLock();
73 | }
74 | }
75 |
76 | function setup() {
77 | var doc = SpreadsheetApp.getActiveSpreadsheet();
78 | SCRIPT_PROP.setProperty("key", doc.getId());
79 | }
80 |
--------------------------------------------------------------------------------
/src/data/analytics.js:
--------------------------------------------------------------------------------
1 | define(function(require, exports, module) {
2 | 'use strict';
3 | var flight = require('flight');
4 | var $ = require('jquery');
5 | var _ = require('lodash');
6 | $("")
7 | .attr('type', 'text/javascript')
8 | .attr('src', '//www.google-analytics.com/analytics.js')
9 | .appendTo('head');
10 |
11 | module.exports = flight.component(function analytics() {
12 | this.defaultAttrs({
13 | codeForBostonTracker: "UA-37610225-5",
14 | enabled: true,
15 | detailEnabled: true
16 | });
17 |
18 | this.track = function() {
19 | if (_.isFunction(window.ga)) {
20 | window.ga.apply(this, arguments);
21 | } else {
22 | if (!window.ga) {
23 | window.GoogleAnalyticsObject = 'ga';
24 | window.ga = { q: [], l: Date.now() };
25 | }
26 | window.ga.q.push(arguments);
27 | }
28 | };
29 |
30 | this.trackAll = function(command) {
31 | if (!this.attr.enabled) {
32 | return;
33 | }
34 | var args = Array.prototype.slice.apply(arguments).slice(1);
35 | _.each(
36 | this.trackers,
37 | function(tracker) {
38 | var subCommand = tracker + '.' + command;
39 | this.track.apply(this, [subCommand].concat(args));
40 | },
41 | this);
42 | };
43 |
44 | this.configureAnalytics = function(ev, config) {
45 | this.attr.enabled = config.analytics.enabled;
46 | if (!this.attr.enabled) {
47 | return;
48 | }
49 |
50 | this.attr.detailEnabled = config.analytics.detail_enabled;
51 |
52 | var hostname = window.location.hostname;
53 | if (_.isString(config.analytics.hostname)) {
54 | hostname = config.analytics.hostname;
55 | }
56 |
57 | var trackers = [];
58 | if (!config.analytics['private']) {
59 | trackers.push('cfb');
60 | this.track("create", this.attr.codeForBostonTracker, hostname,
61 | {name: 'cfb'});
62 | }
63 | if (config.analytics.google_tracker) {
64 | trackers.push('user');
65 | this.track("create", config.analytics.google_tracker, hostname,
66 | {name: 'user'});
67 | }
68 | this.trackers = trackers;
69 | this.trackAll("send", "pageview");
70 | };
71 |
72 | this.trackFacet = function(ev, facets) {
73 | var detail = null;
74 | if (this.attr.detailEnabled) {
75 | detail = JSON.stringify(facets);
76 | }
77 | this.trackAll('send', 'event', 'click', 'facets', detail);
78 | };
79 |
80 | this.trackFeature = function(ev, feature) {
81 | var detail = null;
82 | if (this.attr.detailEnabled) {
83 | detail = feature.geometry.coordinates.join(',');
84 | }
85 | this.trackAll('send', 'event', 'click', 'feature', detail);
86 | };
87 |
88 | this.trackSearch = function(ev, search) {
89 | var detail = null;
90 | if (this.attr.detailEnabled) {
91 | detail = search.query;
92 | }
93 | this.trackAll('send', 'event', 'click', 'search', detail);
94 | };
95 |
96 | this.after('initialize', function() {
97 | this.on(document, 'config', this.configureAnalytics);
98 | this.on(document, 'selectFeature', this.trackFeature);
99 | this.on(document, 'uiFilterFacet', this.trackFacet);
100 | this.on(document, 'uiSearch', this.trackSearch);
101 | });
102 | });
103 | });
104 |
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 | # GetHelpLex [](https://travis-ci.org/openlexington/gethelplex) [](http://waffle.io/openlexington/gethelplex)
2 |
3 | ## I want to help develop Finda
4 |
5 | Great! Quick setup:
6 |
7 | npm install
8 | npm install -g http-server
9 | npm start
10 |
11 | Visit [localhost:8080](http://localhost:8080/) to see the app.
12 |
13 | A bit more background:
14 |
15 | The project is based on Code for Boston's [finda](https://github.com/codeforboston/finda) project. In Lexington we've customized it for our needs:
16 |
17 | * The facilities [are read](https://github.com/openlexington/gethelplex/blob/gh-pages/src/data/geojson.js#L10) from a Google spreadsheet using the [Tabletop.js](https://github.com/jsoma/tabletop) library. This way our stakeholders have realtime ability to update facilities.
18 | * We hacked the [facet handling](https://github.com/openlexington/gethelplex/blob/gh-pages/src/ui/facet.js) to guide the user through a 'survey'. It narrows down the facilities based on type of treatment, the insurance they accept, etc.
19 |
20 | Let's say you want to new information about facilities like "do they let you smoke." You would want to add a column to the [facilities spreadsheet](https://docs.google.com/spreadsheets/d/1LZRal5xPL6fe3BOlBBHc8RdsOPCXQEc5vers2dsg1M8/edit#gid=145432932) called smoking_permited (or similar):
21 |
22 | * make a copy of the existing spreadsheet
23 | * copy paste the new spreadsheet's key to the [Tabletop.js init](https://github.com/openlexington/gethelplex/blob/gh-pages/src/data/geojson.js#L12)
24 | * then you'll update [config.json](https://github.com/openlexington/gethelplex/blob/gh-pages/config.json) [todo, flesh this step out more :)]
25 |
26 | A lot of Code for Boston's [development documentation](https://github.com/codeforboston/finda/wiki/Developing-Finda) is still relevant. Let us know if you hit any key differences for Lexington and we'll update this readme!
27 |
28 | Look in the [waffle board](https://waffle.io/openlexington/finda) for priority issues.
29 |
30 | ## How to Test
31 |
32 | You can run tests once by running: `npm test`
33 |
34 | Keep test server running to speed up tests. Start test server:
35 |
36 | npm run test-server
37 |
38 | Kick off a test run when the test server is running:
39 |
40 | npm run test-client
41 |
42 | ## Analytics and feedback
43 |
44 | GetHelpLex uses Google Tag Manager to manage Google Analytics as described in the [Unified Analytics repository](https://github.com/laurenancona/unified-analytics).
45 |
46 | GetHelpLex posts feedback to a Google Spreadsheet [as described here](https://mashe.hawksey.info/2014/07/google-sheets-as-a-database-insert-with-apps-script-using-postget-methods-with-ajax-example/).
47 | As a backup, feedback is tracked using the [ga-feedback approach](https://github.com/luckyshot/ga-feedback) managed by Google Tag Manager [as described here](http://erikschwartz.net/2016-01-23-google-analytics-events-in-google-tag-manager/).
48 |
49 | Feedback is emailed to addresses defined in the script attached to the [feedback spreadsheet](https://docs.google.com/spreadsheets/d/1lP-OsypwXFkH-S3F3Re34fBPSYgpr1ZXY6bRD85w3V8/edit).
50 |
51 | To make changes to the script that handles feedback requests:
52 |
53 | * edit in the Appscript editor
54 | * `Publish` > `Deploy as webapp` > `Version: new`
55 |
56 | ## Add map coordinates for new facilities
57 |
58 | 
59 |
--------------------------------------------------------------------------------
/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "project": {
3 | "name": "GetHelpLex",
4 | "description": "
Finda is a generic \"find-a\" app for geographic datasets.
",
5 | "contact": "Please send feedback, ideas, and bug reports to our
Github page."
6 | },
7 | "map": {
8 | "preview_attribute": "organization_name",
9 | "center":[37.6864, -85.2219],
10 | "zoom":7,
11 | "maxZoom":16,
12 | "maxBounds": [
13 | [
14 | 36.4964,
15 | -89.5715
16 | ],
17 | [
18 | 38.6855,
19 | -81.8509
20 | ]
21 | ]
22 | },
23 | "properties": [
24 | "organization_name",
25 | "address",
26 | {"name": "address", "title": "directions", "directions": true },
27 | {"name": "web_url", "title": "website", "url": true },
28 | {"name": "contact_names", "title": "Contact Information" },
29 | "contact_emails",
30 | "phone_numbers",
31 |
32 | {"name": "facility_type", "title": "Facility Type" },
33 | {"name": "out_patient", "title": "Outpatient Services" },
34 | {"name": "residential_offered", "title": "Residential Services" },
35 | {"name": "medical_detox_offered", "title": "Medical Detox Services" },
36 | {"name": "assessment_offered", "title": "Assessment Services" },
37 | {"name": "gender", "title": "Genders Served" },
38 | {"name": "pregnancy_services", "title": "Services for Pregnant Women" },
39 | {"name": "age", "title": "Ages Served" },
40 | {"name": "insurance", "title": "Payment Accepted"},
41 | {"name": "additional_notes", "title": "Additional Notes"}
42 | ],
43 | "list": [
44 | "organization_name",
45 | "city",
46 | "phone_numbers"
47 | ],
48 | "search": {
49 | "geosearch": true,
50 | "full_text": {
51 | "keys": [
52 | "address",
53 | "organization_name",
54 | "community",
55 | "youth_category",
56 | "service_class_level_2",
57 | "additional_notes",
58 | "county"]
59 | }
60 | },
61 | "facets": {
62 | "facility_type": {
63 | "title": "Type of Treatment",
64 | "survey_title": "Treatment can be delivered on an outpatient or residential basis.
I am interested in learning about substance use treatment resources that are:",
65 | "type": "single"
66 | },
67 | "out_patient": {
68 | "title": "Outpatient",
69 | "type": "single",
70 | "dependency": "outpatient_offered",
71 | "survey_title": "Outpatient treatments I am interested in:"
72 | },
73 | "gender": {
74 | "title": "Gender",
75 | "survey_title": "I am interested in services for a:",
76 | "type": "single"
77 | },
78 | "pregnancy": {
79 | "title": "Services for a Pregnant Woman?",
80 | "survey_title": "I am interested in services for someone who is pregnant:",
81 | "dependency": "gender_female",
82 | "type": "single"
83 | },
84 | "age": {
85 | "title": "Age",
86 | "survey_title": "I am interested in services for a:",
87 | "type": "single"
88 | },
89 | "insurance": {
90 | "title": "Payment Accepted",
91 | "survey_title": "I am interested in services that accept these payments:",
92 | "type": "single"
93 | },
94 | "county": {
95 | "title": "County",
96 | "type": "single"
97 | }
98 | },
99 | "analytics": {
100 | "enabled": true,
101 | "private": false,
102 | "google_tracker": null,
103 | "hostname": "auto",
104 | "detail_enabled": true
105 | },
106 | "geojson_source": "data.geojson"
107 | }
108 |
--------------------------------------------------------------------------------
/test/spec/ui/facet_spec.js:
--------------------------------------------------------------------------------
1 | define(['test/mock', 'jquery'], function(mock, $) {
2 | 'use strict';
3 | describeComponent('ui/facet', function() {
4 | var mockFacets = {
5 | 'services_offered': [{value: 'first', count: 1},
6 | {value: 'second', count: 2, selected: true},
7 | {value: 'third', count: 0}]
8 | };
9 |
10 | beforeEach(function() {
11 | setupComponent();
12 | spyOnEvent(document, 'uiFilterFacet');
13 | spyOnEvent(document, 'uiClearFacets');
14 | });
15 | describe('on config', function() {
16 | it('stores the facet config', function() {
17 | $(document).trigger('config', mock.config);
18 | expect(this.component.facetConfig).toEqual(
19 | mock.config.facets);
20 | });
21 | });
22 | describe('on dataFacets', function() {
23 | beforeEach(function() {
24 | this.component.trigger('config', mock.config);
25 | this.component.trigger('dataFacets', mockFacets);
26 | // skip past intro question to first facet
27 | this.component.setFacetOffset(0);
28 | });
29 | it('renders the name of the facet in an h4', function() {
30 | expect(this.$node.find('h4').text()).toEqual('What kind of services do you want?');
31 | });
32 | it('renders each facet value as a checkbox', function() {
33 | expect(this.$node.find('input').length).toEqual(2);
34 | expect(this.$node.find('input:first').attr('name')).toEqual('first');
35 | });
36 | it('renders the label with the facet value', function() {
37 | var text = this.$node.find("label:first").text();
38 | expect(text).toContain('first');
39 | });
40 | it('renders a checked checkbox if selected is true', function() {
41 | expect(this.$node.find("input[name=second]").val()).toEqual('on');
42 | });
43 | it('does not render labels with a 0 count', function() {
44 | expect(this.$node.find("input[name=third]").length).toEqual(0);
45 | });
46 | });
47 |
48 | describe('on click', function() {
49 | // NB: "second" is already selected, per the mockFacets above
50 | beforeEach(function() {
51 | this.component.trigger('config', mock.config);
52 | this.component.trigger('dataFacets', mockFacets);
53 | // skip past intro question to first facet
54 | this.component.setFacetOffset(0);
55 | });
56 | it('sends a "uiFilterFacet" event with the selected facets', function () {
57 | this.component.$node.find('input:first').click();
58 | waits(1);
59 | runs(function() {
60 | expect('uiFilterFacet').toHaveBeenTriggeredOnAndWith(
61 | document,
62 | {facet: 'services_offered',
63 | selected: ['first', 'second']
64 | });
65 | });
66 | });
67 | it('sends a "uiFilterFacet" event with no facets', function () {
68 | this.component.$node.find('input:eq(1)').click();
69 | waits(1);
70 | runs(function() {
71 | expect('uiFilterFacet').toHaveBeenTriggeredOnAndWith(
72 | document,
73 | {facet: 'services_offered',
74 | selected: []
75 | });
76 | });
77 | });
78 | });
79 |
80 | describe('on click clear facet', function() {
81 | beforeEach(function() {
82 | this.component.trigger('config', mock.config);
83 | this.component.trigger('dataFacets', mockFacets);
84 | // skip past intro question to first facet
85 | this.component.setFacetOffset(0);
86 | });
87 | it('sends a "uiClearFacets" event', function () {
88 | this.component.$node.find('.clear-facets').click();
89 | waits(1);
90 | runs(function() {
91 | expect('uiClearFacets').toHaveBeenTriggeredOn(document);
92 | });
93 | });
94 | });
95 | });
96 | });
97 |
--------------------------------------------------------------------------------
/src/infotemplates.js:
--------------------------------------------------------------------------------
1 | define(function(require, exports) {
2 | 'use strict';
3 | var Handlebars = require('handlebars'),
4 | _ = require('lodash');
5 |
6 | var templates = {
7 | url: Handlebars.compile('
{{title}} '),
8 | image: Handlebars.compile('
'),
9 | title: Handlebars.compile('
'),
10 | list: Handlebars.compile('
{{#list}} {{{this}}} {{/list}} '),
11 | directions: Handlebars.compile('
{{title}} '),
12 | simple: Handlebars.compile('{{text}}'),
13 | phone_numbers: Handlebars.compile('
{{text}} '),
14 | popup: Handlebars.compile('
{{#popup}}
{{{rendered}}}
{{/popup}}
')
15 | };
16 | var formatters = {
17 | url: function(value, property) {
18 | var title = property.title || '[link]';
19 | return templates.url({title: title,
20 | url: value});
21 | },
22 |
23 | image: function(value) {
24 | return templates.image({url: value});
25 | },
26 |
27 | title: function(value, property) {
28 | return templates.title({title: property.title,
29 | rendered: format(value)});
30 | },
31 |
32 | list: function(value, property) {
33 | if (value.length === 0) {
34 | return '';
35 | } else if (value.length === 1) {
36 | return formatters.simple(value[0], property);
37 | }
38 | return templates.list({
39 | list: _.map(value, formatters.simple)
40 | });
41 | },
42 |
43 | phone_numbers: function(value) {
44 | return templates.phone_numbers({text: value});
45 | },
46 |
47 | simple: function(value) {
48 | var text = value;
49 | return templates.simple({text: text}).replace(
50 | /\n/g, '
');
51 | },
52 |
53 | directions: function(value, property) {
54 | var title = property.title || "directions";
55 | return templates.directions({title: title,
56 | directions: encodeURIComponent(
57 | value.replace('\n', ' '))});
58 | }
59 | };
60 |
61 | function format(value, property) {
62 | property = property || {};
63 | var formatter;
64 | if (property.url) {
65 | formatter = 'url';
66 | } else if (property.image) {
67 | formatter = 'image';
68 | } else if (property.directions) {
69 | formatter = 'directions';
70 | } else if (property.title) {
71 | formatter = 'title';
72 | } else if (_.isArray(value)) {
73 | formatter = 'list';
74 | } else if (property === "phone_numbers") {
75 | formatter = 'phone_numbers';
76 | } else {
77 | formatter = 'simple';
78 | }
79 | // apply the discovered formatter to the data
80 | return formatters[formatter](value, property);
81 | }
82 |
83 | exports.popup = function(propertiesToRender, featureProperties, featureId) {
84 | var popup = [],
85 | rendered;
86 | _.each(propertiesToRender, function(property) {
87 |
88 | var key;
89 | if (typeof property === "string") {
90 | key = property;
91 | } else if (typeof property === "object") {
92 | key = property.name;
93 | }
94 |
95 | var value = featureProperties[key];
96 | if (value !== undefined && (value.length === undefined || value.length !== 0)) {
97 | rendered = format(value, property);
98 | if (rendered) {
99 | popup.push({
100 | klass: key.replace(' ', '-'),
101 | rendered: rendered
102 | });
103 | }
104 | }
105 | });
106 | return templates.popup({popup: popup, featureId: featureId});
107 | };
108 | });
109 |
--------------------------------------------------------------------------------
/lib/fuse.min.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @license
3 | * Fuse - Lightweight fuzzy-search
4 | *
5 | * Copyright (c) 2012 Kirollos Risk
.
6 | * All Rights Reserved. Apache Software License 2.0
7 | *
8 | * Licensed under the Apache License, Version 2.0 (the "License");
9 | * you may not use this file except in compliance with the License.
10 | * You may obtain a copy of the License at
11 | *
12 | * http://www.apache.org/licenses/LICENSE-2.0
13 | *
14 | * Unless required by applicable law or agreed to in writing, software
15 | * distributed under the License is distributed on an "AS IS" BASIS,
16 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17 | * See the License for the specific language governing permissions and
18 | * limitations under the License.
19 | */
20 | !function(t){function e(t,n){this.list=t,this.options=n=n||{};var i,o,s;for(i=0,keys=["sort","includeScore","shouldSort"],o=keys.length;o>i;i++)s=keys[i],this.options[s]=s in n?n[s]:e.defaultOptions[s];for(i=0,keys=["searchFn","sortFn","keys","getFn"],o=keys.length;o>i;i++)s=keys[i],this.options[s]=n[s]||e.defaultOptions[s]}var n=function(t,e){if(e=e||{},this.options=e,this.options.location=e.location||n.defaultOptions.location,this.options.distance="distance"in e?e.distance:n.defaultOptions.distance,this.options.threshold="threshold"in e?e.threshold:n.defaultOptions.threshold,this.options.maxPatternLength=e.maxPatternLength||n.defaultOptions.maxPatternLength,this.pattern=e.caseSensitive?t:t.toLowerCase(),this.patternLen=t.length,this.patternLen>this.options.maxPatternLength)throw new Error("Pattern length is too long");this.matchmask=1<i;)this._bitapScore(e,l+o)<=u?i=o:d=o,o=Math.floor((d-i)/2+i);for(d=o,s=Math.max(1,l-o+1),r=Math.min(l+o,c)+this.patternLen,a=Array(r+2),a[r+1]=(1<=s;n--)if(p=this.patternAlphabet[t.charAt(n-1)],a[n]=0===e?(a[n+1]<<1|1)&p:(a[n+1]<<1|1)&p|((h[n+1]|h[n])<<1|1)|h[n+1],a[n]&this.matchmask&&(g=this._bitapScore(e,n-1),u>=g)){if(u=g,f=n-1,m.push(f),!(f>l))break;s=Math.max(1,2*l-f)}if(this._bitapScore(e+1,l)>u)break;h=a}return{isMatch:f>=0,score:g}};var i={deepValue:function(t,e){for(var n=0,e=e.split("."),i=e.length;i>n;n++){if(!t)return null;t=t[e[n]]}return t}};e.defaultOptions={id:null,caseSensitive:!1,includeScore:!1,shouldSort:!0,searchFn:n,sortFn:function(t,e){return t.score-e.score},getFn:i.deepValue,keys:[]},e.prototype.search=function(t){var e,n,o,s,r,a=new this.options.searchFn(t,this.options),h=this.list,p=h.length,c=this.options,l=this.options.keys,u=l.length,f=[],d={},g=[],m=function(t,e,n){void 0!==t&&null!==t&&"string"==typeof t&&(s=a.search(t),s.isMatch&&(r=d[n],r?r.score=Math.min(r.score,s.score):(d[n]={item:e,score:s.score},f.push(d[n]))))};if("string"==typeof h[0])for(var e=0;p>e;e++)m(h[e],e,e);else for(var e=0;p>e;e++)for(o=h[e],n=0;u>n;n++)m(this.options.getFn(o,l[n]),o,e);c.shouldSort&&f.sort(c.sortFn);for(var y=c.includeScore?function(t){return f[t]}:function(t){return f[t].item},L=c.id?function(t){return i.deepValue(y(t),c.id)}:function(t){return y(t)},e=0,v=f.length;v>e;e++)g.push(L(e));return g},"object"==typeof exports?module.exports=e:"function"==typeof define&&define.amd?define(function(){return e}):t.Fuse=e}(this);
--------------------------------------------------------------------------------
/src/data/geojson.js:
--------------------------------------------------------------------------------
1 | define(function(require, exports, module) {
2 | 'use strict';
3 | var flight = require('flight');
4 | var Tabletop = require('Tabletop');
5 | var _ = require('lodash');
6 |
7 | module.exports = flight.component(function loader() {
8 | this.onConfig = function onConfig() {
9 | // load the geojson
10 | Tabletop.init( {
11 | // key: '1oWIrEg77ZSOiYGUA6H4b1wlvtC8pIrvdznQDcbLEUPg',
12 | key: '1LZRal5xPL6fe3BOlBBHc8RdsOPCXQEc5vers2dsg1M8',
13 | callback: function(data) {
14 | var facetTitles = data.splice(0, 1)[0];
15 | // throw away survey rows
16 | data.splice(0, 2);
17 | this.trigger('facetTitles', facetTitles);
18 | this.trigger('data', this.processData(this.csvToGeojson(data)));
19 | }.bind(this),
20 | simpleSheet: true
21 | });
22 | };
23 |
24 | this.csvRowToProperties = function csvRowToProperties(csvRow, facetValues) {
25 | var properties = {
26 | "organization_name": csvRow.organization_name,
27 | "phone_numbers": csvRow.phone_numbers,
28 | "address": csvRow.address + " " + csvRow.city + ", Kentucky",
29 | "city": csvRow.city,
30 | "county": csvRow.county,
31 | "web_url": csvRow.web_url,
32 | "additional_notes": csvRow.additional_notes
33 | };
34 |
35 | _.each(facetValues, function(facet, facetValue) {
36 | if (! properties[facet]) { properties[facet] = []; }
37 | if (csvRow[facetValue] === "1") {
38 | properties[facet].push(facetValue);
39 | }
40 | });
41 | return properties;
42 | };
43 |
44 | this.csvRowToFeature = function csvRowToFeature(csvRow, facetValues) {
45 | return {
46 | "type": "Feature",
47 | "geometry": {
48 | "type": "Point",
49 | "coordinates": [
50 | csvRow.lng,
51 | csvRow.lat
52 | ]
53 | },
54 | "properties": this.csvRowToProperties(csvRow, facetValues)
55 | };
56 | };
57 |
58 | this.csvToGeojson = function csvToGeojson(csv) {
59 | var facetValues = {
60 | outpatient_offered: "facility_type",
61 | residential_offered: "facility_type",
62 | medical_detox_offered: "facility_type",
63 | assessment_offered: "facility_type",
64 | outpatient_intensive: "out_patient",
65 | outpatient_services: "out_patient",
66 | outpatient_mat: "out_patient",
67 | outpatient_twelvestep: "out_patient",
68 | residential_detox_offered: "residential",
69 | gender_male: "gender",
70 | gender_female: "gender",
71 | pregnancy_services: "pregnancy",
72 | no_pregnancy_services: "pregnancy",
73 | age_child: "age",
74 | age_adult: "age",
75 | insurance_medicare: "insurance",
76 | insurance_medicaid: "insurance",
77 | insurance_gov_funded: "insurance",
78 | insurance_private: "insurance",
79 | insurance_payment_assistance: "insurance",
80 | insurance_no_fee: "insurance",
81 | insurance_self_pay: "insurance",
82 | county: "county"
83 | };
84 | csv = _.filter(csv, function(row) {
85 | return row.organization_name !== "";
86 | });
87 | var features = _.map(csv, function(row) {
88 | return this.csvRowToFeature(row, facetValues);
89 | }.bind(this));
90 |
91 | return {
92 | "type": "FeatureCollection",
93 | "features": features
94 | };
95 | };
96 |
97 | this.processData = function processData(data) {
98 | // give each feature an string ID if it doesn't have one already
99 | data.features.forEach(function(feature, index) {
100 | if (!feature.id) {
101 | feature.id = 'finda-' + index;
102 | } else {
103 | feature.id = feature.id.toString();
104 | }
105 | });
106 | return data;
107 | };
108 |
109 | this.after('initialize', function() {
110 | // load the data
111 | this.on(document, 'config', this.onConfig);
112 | });
113 | });
114 | });
115 |
--------------------------------------------------------------------------------
/src/templates/medicalDetoxPrograms.html:
--------------------------------------------------------------------------------
1 | Medical Detox facilities in the Lexington area include:
2 |
3 | Good Samaritan Hospital - Emergency Room
4 | 310 S. Limestone
5 | Lexington, KY 40508
6 | 859-226-7070
7 | Note: Good Samaritan provides medical detox depending on assessed need. Please contact the hospital for more information.
8 |
9 | The Ridge
10 | 3050 Rio Dosa Drive
11 | Lexington, KY 40509
12 | 859-269-2325
13 | Note: Call the Assessment Department at 859-269-2325 to schedule an initial assessment. The Ridge accepts Medicaid up to age 21, Medicare, and most private insurances and operates 24 hours a day/365 days a year.
14 |
15 | St. Joseph Hospital - Emergency Room
16 | 1 Saint Joseph Drive
17 | Lexington, KY 40504
18 | 859-313-1000
19 | Note: Upon arrival to the emergency room, inform the person at the admission desk that you need an Our Lady of Peace substance abuse assessment. At that point an assessor from Our Lady of Peace will be contacted to complete your assessment. Referral will be based on the outcome of the assessment.
20 |
21 | Saint Joseph East - Emergency Room
22 | 150 North Eagle Creek Drive
23 | Lexington, KY 40509
24 | 859-967-5000
25 | Note: Upon arrival to the emergency room, inform the person at the admission desk that you need an Our Lady of Peace substance abuse assessment. At that point an assessor from Our Lady of Peace will be contacted to complete your assessment. Referral will be based on the outcome of the assessment.
26 |
27 | Saint Joseph Berea - Emergency Room
28 | 305 Estill Street
29 | Berea, KY 40403
30 | 859-986-3151
31 | Note: Upon arrival to the emergency room, inform the person at the admission desk that you need an Our Lady of Peace substance abuse assessment. At that point an assessor from Our Lady of Peace will be contacted to complete your assessment. Referral will be based on the outcome of the assessment.
32 |
33 | Saint Joseph Jessamine - Emergency Room
34 | 1250 Keene Road
35 | Nicholasville, KY 40356
36 | 859-887-4100
37 | Note: Upon arrival to the emergency room, inform the person at the admission desk that you need an Our Lady of Peace substance abuse assessment. At that point an assessor from Our Lady of Peace will be contacted to complete your assessment. Referral will be based on the outcome of the assessment.
38 |
39 | Stepworks - London
40 | 3825 Marydell Road
41 | London, KY 40741
42 | 800-545-9031
43 | Note: Please contact program regarding specific details.
44 |
45 | Stoner Creek
46 | 9 Linville Drive
47 | Paris, KY 40361
48 | 1-888-394-4673
49 | Note: Stoner Creek provides medical detox. Please call for an assessment and specifics regarding insurances accepted and specific services provided.
50 |
51 | Trillium Center Corbin
52 | Baptist Health Corbin
53 | 1 Trillium Center
54 | Corbin, KY 40701
55 | 606-523-5900 or 800-395-4435
56 | Note: Please contact program for specifics regarding insurances accepted and services provided.
57 |
58 | Recovery Works - Georgetown
59 | 3107 Cincinnati Road
60 | Georgetown, KY 42701
61 | 502-570-9313
62 | Note: Please contact program for specifics regarding insurances accepted and services provided.
63 |
64 | Recovery Works - Elizabethtown
65 | 100 Diecks Drive
66 | Elizabethtown, KY 42701
67 | 888-982-1244
68 | Note: Please contact program for specifics regarding insurances accepted and services provided.
69 |
70 | Recovery Works - Mayfield
71 | 4747 Old Dublin Road
72 | Mayfield, KY 42066
73 | 270-623-8500
74 | Note: Please contact program for specifics regarding insurances accepted and services provided.
75 |
--------------------------------------------------------------------------------
/src/ui/list.js:
--------------------------------------------------------------------------------
1 | define(function(require, exports, module) {
2 | 'use strict';
3 | var flight = require('flight');
4 | var _ = require('lodash');
5 | var $ = require('jquery');
6 | var templates = require('infotemplates');
7 | var timedWithObject = require('timed_with_object');
8 |
9 | module.exports = flight.component(function list() {
10 | this.defaultAttrs({
11 | listItemSelector: 'li'
12 | });
13 |
14 | function $elementForFeature(feature) {
15 | var liArray = this.$node.find('li').filter(function() {
16 | var nodeFeature = $(this).data('feature');
17 | if (!nodeFeature) {
18 | return false;
19 | }
20 | return _.isEqual(nodeFeature, feature);
21 | });
22 | return $(liArray[0]);
23 | }
24 |
25 | function compareListItems(a, b) {
26 | a = a._text;
27 | b = b._text;
28 | if (a < b) {
29 | return -1;
30 | } else if (a > b) {
31 | return 1;
32 | } else {
33 | return 0;
34 | }
35 | }
36 |
37 | this.configureList = function(ev, config) {
38 | var listConfig = config.list;
39 | if (!listConfig) {
40 | this.teardown();
41 | return;
42 | }
43 | this.trigger('listStarted', {});
44 | this.render = _.partial(templates.popup, config.list);
45 | this.renderFull = _.partial(templates.popup, config.properties);
46 | };
47 |
48 | this.loadData = function(ev, data) {
49 | var $ul = this.$node.empty().html('').find('ul');
50 | timedWithObject(
51 | data.features,
52 | function(feature, l) {
53 | var $li = $(" ").html(this.render(feature.properties, feature.id))
54 | .addClass('item')
55 | .data('feature', feature);
56 | $li._text = $li.text();
57 | l.push($li);
58 | return l;
59 | },
60 | [],
61 | this).then(function(l) {
62 | l.sort(compareListItems);
63 | $ul.append(l);
64 | this.trigger('listFinished', {});
65 | }.bind(this));
66 | };
67 |
68 | this.filterData = function(ev, data) {
69 | this.trigger('listFilteringStarted', {});
70 | this.$node.find('li.item').hide().filter(function() {
71 | var $li = $(this);
72 | return _.contains(data.featureIds, $li.data('feature').id);
73 | }).show();
74 | this.trigger('listFinished', {});
75 | };
76 |
77 | this.onFeatureClick = function onFeatureClick(ev) {
78 | var $li = $(ev.target).closest('li.item');
79 | var feature = $li.data('feature');
80 | this.selectedLi = $li;
81 | this.trigger('selectFeature', feature);
82 | };
83 |
84 | this.onFeatureSelected = function onFeatureSelected(ev, feature) {
85 | var $selectedItem = $elementForFeature.call(this, feature);
86 | var propsWithTitles = this.addFacetTitles(feature.properties, this.facetTitles);
87 |
88 | // set url to blah.com#finda-17
89 | window.location.hash = feature.id;
90 | $selectedItem.html(this.renderFull(propsWithTitles, feature.id));
91 |
92 | // does not clear previous selections so they remain findable later
93 | $selectedItem.addClass('selected-facility');
94 | };
95 |
96 | this.addFacetTitles = function(featureProperties, facetTitles) {
97 | var propsWithTitles = _.clone(featureProperties);
98 | // can probably use map rather than each
99 | _.each(propsWithTitles, function(values, key) {
100 | if (facetTitles && _.isArray(values)) {
101 | propsWithTitles[key] = _.map(values, function(value) {
102 | return facetTitles[value];
103 | }.bind(this));
104 | }
105 | }.bind(this));
106 | return propsWithTitles;
107 | };
108 |
109 | this.after('initialize', function() {
110 | this.on(document, 'config', this.configureList);
111 | this.on(document, 'data', this.loadData);
112 | this.on(document, 'dataFiltered', this.filterData);
113 | this.on(document, 'selectFeature', this.onFeatureSelected);
114 | this.on('click', {
115 | listItemSelector: this.onFeatureClick
116 | });
117 | this.on(document, 'uiHideResults', function() {
118 | this.$node.hide();
119 | });
120 | this.on(document, 'facetTitles', function(ev, facetTitles) {
121 | this.facetTitles = facetTitles;
122 | });
123 | });
124 | });
125 | });
126 |
--------------------------------------------------------------------------------
/src/templates/assessment.html:
--------------------------------------------------------------------------------
1 | Where to Get an Assessment for Substance Use Disorder
2 |
3 | Bluegrass.org
4 | 1351 Newtown Pike
5 | Lexington, KY 40511
6 | 859-253-1686
7 | Note: Please contact Bluegrass.org to schedule an appointment.
8 |
9 | Good Samaritan Hospital-Emergency Room
10 | 310 S. Limestone
11 | Lexington, KY 40508
12 | 859-226-7070
13 | Note: Good Samaritan provides medical detox depending on assessed need. Please contact the hospital for more information.
14 |
15 | The Ridge
16 | 3050 Rio Dosa Drive
17 | Lexington, KY 40509
18 | 859.269.2325
19 | Note: Call the Assessment Department at 859.269.2325 to schedule an initial assessment. The Ridge accepts Medicaid up to age 21, Medicare, and most private insurances and operates 24 hours a day/365 days a year.
20 |
21 | St. Joe Hospital-Emergency Room
22 | 1 Saint Joseph Drive
23 | Lexington, KY 40504
24 | 859.313.1000
25 | Note: Upon arrival to the emergency room, inform the person at the admission desk that you need an Our Lady of Peace substance abuse assessment. At that point an assessor from Our Lady of Peace will be contacted to complete your assessment. Referral will be based on the outcome of the assessment.
26 |
27 | Saint Joseph East-Emergency Room
28 | 150 North Eagle Creek Drive
29 | Lexington, KY 40509
30 | 859.967.5000
31 | Note: Upon arrival to the emergency room, inform the person at the admission desk that you need an Our Lady of Peace substance abuse assessment. At that point an assessor from Our Lady of Peace will be contacted to complete your assessment. Referral will be based on the outcome of the assessment.
32 |
33 | Saint Joseph Berea-Emergency Room
34 | 305 Estill Street
35 | Berea, KY 40403
36 | 859.986.3151
37 | Note: Upon arrival to the emergency room, inform the person at the admission desk that you need an Our Lady of Peace substance abuse assessment. At that point an assessor from Our Lady of Peace will be contacted to complete your assessment. Referral will be based on the outcome of the assessment.
38 |
39 | Saint Joseph Jessamine-Emergency Room
40 | 1250 Keene Road
41 | Nicholasville, KY 40356
42 | 859.887.4100
43 | Note: Upon arrival to the emergency room, inform the person at the admission desk that you need an Our Lady of Peace substance abuse assessment. At that point an assessor from Our Lady of Peace will be contacted to complete your assessment. Referral will be based on the outcome of the assessment.
44 |
45 | Stepworks-London
46 | 3825 Marydell Road
47 | London, KY 40741
48 | 800-545-9031
49 | Note: Please contact program regarding specific details.
50 |
51 | Stoner Creek
52 | 9 Linville Drive
53 | Paris, KY 40361
54 | 1-888-394-4673
55 | Note: Stoner Creek provides medical detox. Please call for an assessment and specifics regarding insurances accepted and specific services provided.
56 |
57 | Trillium Center Corbin
58 | Baptist Health Corbin
59 | 1 Trillium Center
60 | Corbin, KY 40701
61 | 606-523-5900 or 800-395-4435
62 | Note: Please contact program for specifics regarding insurances accepted and services provided.
63 |
64 | Recovery Works-Georgetown
65 | 3107 Cincinnati Road
66 | Georgetown, KY 42701
67 | 502-570-9313
68 | Note: Please contact program for specifics regarding insurances accepted and services provided.
69 |
70 | Recovery Works-Elizabethtown
71 | 100 Diecks Drive
72 | Elizabethtown, KY 42701
73 | 888-982-1244
74 | Note: Please contact program for specifics regarding insurances accepted and services provided.
75 |
76 | Recovery Works-Mayfield
77 | 4747 Old Dublin Road
78 | Mayfield, KY 42066
79 | 270-623-8500
80 | Note: Please contact program for specifics regarding insurances accepted and services provided.
81 |
--------------------------------------------------------------------------------
/test/spec/data/analytics_spec.js:
--------------------------------------------------------------------------------
1 | define(function() {
2 | 'use strict';
3 | describeComponent('data/analytics', function() {
4 | var config;
5 | beforeEach(function() {
6 | config = {analytics: {}};
7 | setupComponent();
8 | spyOn(this.component, 'track');
9 | spyOn(this.component, 'trackAll');
10 | });
11 |
12 | afterEach(function() {
13 | window.ga = undefined;
14 | });
15 |
16 | describe('track', function() {
17 | beforeEach(function() {
18 | this.component.track.andCallThrough();
19 | });
20 | it('calls window.ga if present', function() {
21 | window.ga = function() {};
22 | spyOn(window, 'ga');
23 | this.component.track(1, 2, 3);
24 | expect(window.ga).toHaveBeenCalledWith(1, 2, 3);
25 | });
26 | it('creates ga object if not present', function() {
27 | window.ga = undefined;
28 | this.component.track(1, 2, 3);
29 | expect(window.ga).toEqual({q: [[1, 2, 3]], l: jasmine.any(Number)});
30 | });
31 | it('uses an existing ga object', function() {
32 | window.ga = {q: []};
33 | this.component.track(1, 2, 3);
34 | expect(window.ga).toEqual({q: [[1, 2, 3]]});
35 | });
36 | });
37 |
38 | it("enabled: false sends no events", function() {
39 | this.component.trigger('config', config);
40 | expect(this.component.track).not.toHaveBeenCalled();
41 | });
42 |
43 | describe("enabled: true", function() {
44 | beforeEach(function() {
45 | config.analytics.enabled = true;
46 | });
47 | it('configures the CodeForBoston tracker (private: false)', function() {
48 | this.component.trigger('config', config);
49 | expect(this.component.track).toHaveBeenCalledWith(
50 | 'create', this.component.attr.codeForBostonTracker, 'localhost',
51 | {name: 'cfb'});
52 | });
53 | it('does not configure the CodeForBoston tracker (private: true)', function() {
54 | config.analytics['private'] = true;
55 | this.component.trigger('config', config);
56 | expect(this.component.track).not.toHaveBeenCalled();
57 | });
58 | it('uses a provided hostname', function() {
59 | config.analytics.hostname = 'auto';
60 | this.component.trigger('config', config);
61 | expect(this.component.track).toHaveBeenCalledWith(
62 | 'create', this.component.attr.codeForBostonTracker, 'auto',
63 | {name: 'cfb'});
64 | });
65 | it('also configures a provided tracker', function() {
66 | config.analytics.google_tracker = 'TRACKER';
67 | this.component.trigger('config', config);
68 | expect(this.component.track).toHaveBeenCalledWith(
69 | 'create', this.component.attr.codeForBostonTracker, 'localhost',
70 | {name: 'cfb'});
71 | expect(this.component.track).toHaveBeenCalledWith(
72 | 'create', 'TRACKER', 'localhost',
73 | {name: 'user'});
74 | });
75 | it('trackAll send the message to each configured tracker', function () {
76 | config.analytics.google_tracker = 'tracker';
77 | this.component.trackAll.andCallThrough();
78 | this.component.trigger('config', config);
79 | expect(this.component.track).toHaveBeenCalledWith(
80 | 'cfb.send', 'pageview');
81 | expect(this.component.track).toHaveBeenCalledWith(
82 | 'user.send', 'pageview');
83 | });
84 | it('sends a pageview event', function() {
85 | this.component.trigger('config', config);
86 | expect(this.component.trackAll).toHaveBeenCalledWith(
87 | 'send', 'pageview');
88 | });
89 |
90 | describe('events (detail: false)', function() {
91 | beforeEach(function() {
92 | this.component.trigger('config', config);
93 | });
94 | it('sends a click event on facet selection', function() {
95 | this.component.trigger('uiFilterFacet', {facet: 'data'});
96 | expect(this.component.trackAll).toHaveBeenCalledWith(
97 | 'send', 'event', 'click', 'facets', null);
98 | });
99 | it('sends a click event on feature selection', function() {
100 | this.component.trigger('selectFeature', {geometry: {
101 | coordinates: [-1, +2]}});
102 | expect(this.component.trackAll).toHaveBeenCalledWith(
103 | 'send', 'event', 'click', 'feature', null);
104 | });
105 | it('sends a click event on search', function() {
106 | this.component.trigger('uiSearch', {search: 'data'});
107 | expect(this.component.trackAll).toHaveBeenCalledWith(
108 | 'send', 'event', 'click', 'search', null);
109 | });
110 | });
111 |
112 | describe('events (detail: true)', function() {
113 | beforeEach(function() {
114 | config.analytics.detail_enabled = true;
115 | this.component.trigger('config', config);
116 | });
117 | it('sends a click event on facet selection', function() {
118 | this.component.trigger('uiFilterFacet', {facet: 'data'});
119 | expect(this.component.trackAll).toHaveBeenCalledWith(
120 | 'send', 'event', 'click', 'facets',
121 | JSON.stringify({facet: 'data'}));
122 | });
123 | it('sends a click event on feature selection', function() {
124 | this.component.trigger('selectFeature', {geometry: {
125 | coordinates: [-1, +2]}});
126 | expect(this.component.trackAll).toHaveBeenCalledWith(
127 | 'send', 'event', 'click', 'feature', '-1,2');
128 | });
129 | it('sends a click event on search', function() {
130 | this.component.trigger('uiSearch', {query: 'data'});
131 | expect(this.component.trackAll).toHaveBeenCalledWith(
132 | 'send', 'event', 'click', 'search', 'data');
133 | });
134 | });
135 | });
136 | });
137 | });
138 |
--------------------------------------------------------------------------------
/lib/es5-shim.min.js:
--------------------------------------------------------------------------------
1 | (function(f){"function"==typeof define?define(f):"function"==typeof YUI?YUI.add("es5",f):f()})(function(){Function.prototype.bind||(Function.prototype.bind=function(d){var c=this;if("function"!=typeof c)throw new TypeError("Function.prototype.bind called on incompatible "+c);var a=n.call(arguments,1),b=function(){if(this instanceof b){var e=function(){};e.prototype=c.prototype;var e=new e,i=c.apply(e,a.concat(n.call(arguments)));return Object(i)===i?i:e}return c.apply(d,a.concat(n.call(arguments)))};
2 | return b});var f=Function.prototype.call,m=Object.prototype,n=Array.prototype.slice,l=f.bind(m.toString),o=f.bind(m.hasOwnProperty);o(m,"__defineGetter__")&&(f.bind(m.__defineGetter__),f.bind(m.__defineSetter__),f.bind(m.__lookupGetter__),f.bind(m.__lookupSetter__));Array.isArray||(Array.isArray=function(d){return l(d)=="[object Array]"});Array.prototype.forEach||(Array.prototype.forEach=function(d,c){var a=j(this),b=-1,e=a.length>>>0;if(l(d)!="[object Function]")throw new TypeError;for(;++b>>0,e=Array(b);if(l(d)!="[object Function]")throw new TypeError(d+" is not a function");for(var i=0;i>>0,e=[],i;if(l(d)!="[object Function]")throw new TypeError(d+" is not a function");for(var f=0;f>>0;if(l(d)!="[object Function]")throw new TypeError(d+" is not a function");for(var e=0;e>>0;if(l(d)!="[object Function]")throw new TypeError(d+" is not a function");for(var e=0;e>>0;if(l(d)!="[object Function]")throw new TypeError(d+" is not a function");if(!a&&arguments.length==1)throw new TypeError("reduce of empty array with no initial value");var b=0,e;if(arguments.length>=2)e=arguments[1];else{do{if(b in c){e=c[b++];break}if(++b>=a)throw new TypeError("reduce of empty array with no initial value");}while(1)}for(;b>>0;if(l(d)!="[object Function]")throw new TypeError(d+" is not a function");if(!a&&arguments.length==1)throw new TypeError("reduceRight of empty array with no initial value");var b,a=a-1;if(arguments.length>=2)b=arguments[1];else{do{if(a in c){b=c[a--];break}if(--a<0)throw new TypeError("reduceRight of empty array with no initial value");}while(1)}do a in this&&(b=d.call(void 0,b,c[a],a,c));while(a--);return b});Array.prototype.indexOf||(Array.prototype.indexOf=
7 | function(d){var c=j(this),a=c.length>>>0;if(!a)return-1;var b=0;arguments.length>1&&(b=p(arguments[1]));for(b=b>=0?b:Math.max(0,a+b);b >>0;if(!a)return-1;var b=a-1;arguments.length>1&&(b=Math.min(b,p(arguments[1])));for(b=b>=0?b:a-Math.abs(b);b>=0;b--)if(b in c&&d===c[b])return b;return-1});if(!Object.keys){var q=!0,r="toString toLocaleString valueOf hasOwnProperty isPrototypeOf propertyIsEnumerable constructor".split(" "),
8 | s=r.length,t;for(t in{toString:null})q=!1;Object.keys=function(d){if(typeof d!="object"&&typeof d!="function"||d===null)throw new TypeError("Object.keys called on a non-object");var c=[],a;for(a in d)o(d,a)&&c.push(a);if(q)for(a=0;a9999?"+":"")+("00000"+Math.abs(b)).slice(0<=b&&b<=9999?-4:-6);for(c=d.length;c--;){a=d[c];a<10&&(d[c]="0"+a)}return b+"-"+d.slice(0,2).join("-")+"T"+d.slice(2).join(":")+"."+("000"+this.getUTCMilliseconds()).slice(-3)+"Z"};Date.now||(Date.now=function(){return(new Date).getTime()});Date.prototype.toJSON||(Date.prototype.toJSON=function(){if(typeof this.toISOString!=
10 | "function")throw new TypeError("toISOString property is not callable");return this.toISOString()});if(!Date.parse||864E13!==Date.parse("+275760-09-13T00:00:00.000Z")){var g=Date,f=function c(a,b,e,f,h,j,l){var k=arguments.length;if(this instanceof g){k=k==1&&String(a)===a?new g(c.parse(a)):k>=7?new g(a,b,e,f,h,j,l):k>=6?new g(a,b,e,f,h,j):k>=5?new g(a,b,e,f,h):k>=4?new g(a,b,e,f):k>=3?new g(a,b,e):k>=2?new g(a,b):k>=1?new g(a):new g;k.constructor=c;return k}return g.apply(this,arguments)},u=RegExp("^(\\d{4}|[+-]\\d{6})(?:-(\\d{2})(?:-(\\d{2})(?:T(\\d{2}):(\\d{2})(?::(\\d{2})(?:\\.(\\d{3}))?)?(?:Z|(?:([-+])(\\d{2}):(\\d{2})))?)?)?)?$"),
11 | h;for(h in g)f[h]=g[h];f.now=g.now;f.UTC=g.UTC;f.prototype=g.prototype;f.prototype.constructor=f;f.parse=function(c){var a=u.exec(c);if(a){a.shift();for(var b=1;b<7;b++){a[b]=+(a[b]||(b<3?1:0));b==1&&a[b]--}var e=+a.pop(),f=+a.pop(),h=a.pop(),b=0;if(h){if(f>23||e>59)return NaN;b=(f*60+e)*6E4*(h=="+"?-1:1)}e=+a[0];if(0<=e&&e<=99){a[0]=e+400;return g.UTC.apply(this,a)+b-126227808E5}return g.UTC.apply(this,a)+b}return g.parse.apply(this,arguments)};Date=f}h="\t\n\x0B\f\r \u00a0\u1680\u180e\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029\ufeff";
12 | if(!String.prototype.trim||h.trim()){h="["+h+"]";var v=RegExp("^"+h+h+"*"),w=RegExp(h+h+"*$");String.prototype.trim=function(){if(this===void 0||this===null)throw new TypeError("can't convert "+this+" to object");return String(this).replace(v,"").replace(w,"")}}var p=function(c){c=+c;c!==c?c=0:c!==0&&(c!==1/0&&c!==-(1/0))&&(c=(c>0||-1)*Math.floor(Math.abs(c)));return c},x="a"!="a"[0],j=function(c){if(c==null)throw new TypeError("can't convert "+c+" to object");return x&&typeof c=="string"&&c?c.split(""):
13 | Object(c)}});
14 |
--------------------------------------------------------------------------------
/test/mock.js:
--------------------------------------------------------------------------------
1 | define({
2 | "config": {
3 | "project": {
4 | "name": "Finda",
5 | "description": " Finda is a generic \"find-a\" app for geographic datasets.
",
6 | "contact": "Please send feedback, ideas, and bug reports! "
7 | },
8 | "map": {
9 | "center":[42.3725, -71.1266],
10 | "zoom":13,
11 | "maxZoom":16,
12 | "maxBounds":[
13 | [39.2, -78.0],
14 | [44.5, -65.0]
15 | ]
16 | },
17 | "properties":[
18 | "organization_name",
19 | "address",
20 | {"name": "address", "title": "directions", "directions": true },
21 | {"name": "web_url", "title": "website", "url": true },
22 | {"name": "contact_names", "title": "Contact Information" },
23 | "contact_emails",
24 | "phone_numbers",
25 |
26 | {"name": "services_offered", "title": "Services" },
27 | {"name": "youth_category", "title": "Type of Organization" },
28 | {"name": "target_populations", "title": "Populations Served" },
29 |
30 | {"name": "additional_notes", "title": "Information"}
31 | ],
32 | "list": [
33 | "organization_name",
34 | "address"
35 | ],
36 | "search": {
37 | "geosearch": true
38 | },
39 | "facets": {
40 | services_offered: {
41 | title: "Services",
42 | survey_title: "What kind of services do you want?",
43 | type: "list"
44 | }
45 | }
46 | },
47 |
48 | "data": {
49 | "type": "FeatureCollection",
50 | "features": [
51 | {
52 | "id": 'finda-1',
53 | "type": "Feature",
54 | "geometry": {
55 | "type": "Point",
56 | "coordinates": [
57 | -72.6411923,
58 | 42.3250492
59 | ]
60 | },
61 | "properties": {
62 | "address": "2 Conz Street\nMaplewood Shops #34\nFranklin, MA 01060",
63 | "organization_name": "Generation Q South: Community Action Youth Programs",
64 | "community": "Northampton",
65 | "services_offered": [
66 | "support group",
67 | "social group"
68 | ],
69 | "web_url": "http://www.communityaction.us/our-groups-programs.html",
70 | "phone_numbers": [
71 | "413-774-7028"
72 | ],
73 | "contact_names": [
74 | "GenQ@communityaction.us"
75 | ],
76 | "contact_emails": [],
77 | "youth_category": "Community Group",
78 | "service_class_level_1": "Support Services",
79 | "service_class_level_2": "Para-professional Counseling, Therapy, and Support",
80 | "service_classes": [
81 | "Clubhouse"
82 | ],
83 | "target_populations": [
84 | "LGBTQ youth 12-21"
85 | ],
86 | "age_range": "",
87 | "additional_notes": []
88 | }
89 | },
90 | {
91 | "id": 'finda-2',
92 | "type": "Feature",
93 | "geometry": {
94 | "type": "Point",
95 | "coordinates": [
96 | -72.59839699999999,
97 | 42.593369
98 | ]
99 | },
100 | "properties": {
101 | "address": "154 Federal Street\nFranklin, MA 01301",
102 | "organization_name": "Generation Q North: Community Action Youth Programs",
103 | "community": "Greenfield",
104 | "services_offered": [
105 | "support group",
106 | "social group"
107 | ],
108 | "web_url": "http://www.communityaction.us/our-groups-programs.html",
109 | "phone_numbers": [
110 | "413-774-7028"
111 | ],
112 | "contact_names": [
113 | "GenQ@communityaction.us"
114 | ],
115 | "contact_emails": [],
116 | "youth_category": "Community Group",
117 | "service_class_level_1": "Support Services",
118 | "service_class_level_2": "Para-professional Counseling, Therapy, and Support",
119 | "service_classes": [
120 | "Clubhouse"
121 | ],
122 | "target_populations": [],
123 | "age_range": "",
124 | "additional_notes": []
125 | }
126 | },
127 | {
128 | "id": 'finda-3',
129 | "type": "Feature",
130 | "geometry": {
131 | "type": "Point",
132 | "coordinates": [
133 | -72.73988969999999,
134 | 42.5964078
135 | ]
136 | },
137 | "properties": {
138 | "address": "53 Elm Street\nFranklin, MA 01370",
139 | "organization_name": "PFLAG Franklin-Hampshire",
140 | "community": "Shellbourne Falls",
141 | "services_offered": [
142 | "support group",
143 | "public education"
144 | ],
145 | "web_url": "http://community.pflag.org/page.aspx?pid=803",
146 | "phone_numbers": [
147 | "(413)-625-6636"
148 | ],
149 | "contact_names": [
150 | "jcmalinski48@gmail.com"
151 | ],
152 | "contact_emails": [],
153 | "youth_category": "Community Group",
154 | "service_class_level_1": "Support Services",
155 | "service_class_level_2": "Para-professional Counseling, Therapy, and Support",
156 | "service_classes": [
157 | "Community Prevention, Education and Outreach"
158 | ],
159 | "target_populations": [
160 | "parent of LGBTQ youth"
161 | ],
162 | "age_range": "",
163 | "additional_notes": []
164 | }
165 | }
166 | ]
167 | },
168 |
169 | openSearchResult: {
170 | "place_id":"98244943",
171 | "licence":"Data \u00a9 OpenStreetMap contributors, ODbL 1.0. http:\/\/www.openstreetmap.org\/copyright",
172 | "osm_type":"relation",
173 | "osm_id":"2315704",
174 | "boundingbox": "box",
175 | "lat":"42.3604823",
176 | "lon":"-71.0595678",
177 | "display_name":"display name",
178 | "class":"place",
179 | "type":"city",
180 | "importance":1.0299782170989,
181 | "icon":"http:\/\/nominatim.openstreetmap.org\/images\/mapicons\/poi_place_city.p.20.png",
182 | "address":{
183 | "city":"Boston",
184 | "county":"Suffolk County",
185 | "state":"Massachusetts",
186 | "country":"United States of America",
187 | "country_code":"us"}},
188 |
189 | parsedSearchResult: {
190 | name: "Boston, Massachusetts",
191 | lat: "42.3604823",
192 | lng: "-71.0595678"
193 | }
194 | });
195 |
--------------------------------------------------------------------------------
/test/spec/ui/map_spec.js:
--------------------------------------------------------------------------------
1 | define(
2 | ['leaflet', 'test/mock', 'jquery', 'lodash'], function(L, mock, $, _) {
3 | 'use strict';
4 | describeComponent('ui/map', function() {
5 | beforeEach(function() {
6 | L.Icon.Default.imagePath = '/base/lib/leaflet/images';
7 | spyOn(L.control, 'locate').andReturn(
8 | jasmine.createSpyObj('Locate', ['addTo']));
9 | spyOn(L.control, 'scale').andReturn(
10 | jasmine.createSpyObj('Scale', ['addTo']));
11 | setupComponent();
12 | });
13 |
14 | describe('initialize', function() {
15 | it('sets up the map', function() {
16 | expect(this.component.map).toBeDefined();
17 | });
18 | it('sets up the scale control', function() {
19 | expect(L.control.scale).toHaveBeenCalledWith();
20 | expect(L.control.scale().addTo).toHaveBeenCalledWith(
21 | this.component.map);
22 | });
23 | it('sets up the locate control', function() {
24 | expect(L.control.locate).toHaveBeenCalledWith();
25 | expect(L.control.locate().addTo).toHaveBeenCalledWith(
26 | this.component.map);
27 | });
28 | });
29 | describe('loading data', function () {
30 | it('config sets up the map object', function() {
31 | this.component.map = jasmine.createSpyObj('Map',
32 | ['setView',
33 | 'setMaxBounds',
34 | 'invalidateSize',
35 | 'addLayer',
36 | 'remove'
37 | ]);
38 | this.component.map.options = {};
39 | this.component.trigger('config', mock.config);
40 | expect(this.component.map.options.maxZoom, mock.config.map.maxZoom);
41 | expect(this.component.map.setView).toHaveBeenCalledWith(
42 | mock.config.map.center, mock.config.map.zoom);
43 | expect(this.component.map.setMaxBounds).toHaveBeenCalledWith(
44 | mock.config.map.maxBounds);
45 | });
46 |
47 | it('data sets up the features', function() {
48 | this.component.trigger('data', mock.data);
49 | waits(25);
50 | runs(function() {
51 | expect(_.size(this.component.layers)).toEqual(3);
52 | });
53 | });
54 |
55 | it('data a second time resets the data', function() {
56 | this.component.trigger('data', {type: 'FeatureCollection',
57 | features: []});
58 | waits(25);
59 | runs(function() {
60 | expect(_.size(this.component.layers)).toEqual(0);
61 | });
62 | });
63 | });
64 |
65 | describe('panning', function() {
66 | beforeEach(function() {
67 | spyOn(this.component.map, 'panTo');
68 | });
69 | it('panTo goes to the lat/lng with maximum zoom', function() {
70 | var latlng = {lat: 1, lng: 2};
71 | this.component.trigger('config', mock.config);
72 | this.component.trigger('panTo', latlng);
73 | expect(this.component.map.panTo).toHaveBeenCalledWith(
74 | latlng);
75 | });
76 | });
77 |
78 | describe('on map move', function() {
79 | it('triggers a mapBounds event with the corners of the map', function() {
80 | var map = this.component.map;
81 |
82 | spyOnEvent(this.component.node, 'mapBounds');
83 |
84 | map.setView([0, 0], 11);
85 |
86 | expect('mapBounds').toHaveBeenTriggeredOn(this.component.node);
87 | expect('mapBounds').toHaveBeenTriggeredOnAndWith(
88 | this.component.node,
89 | {
90 | southWest: [map.getBounds().getSouthWest().lat,
91 | map.getBounds().getSouthWest().lng],
92 | northEast: [map.getBounds().getNorthEast().lat,
93 | map.getBounds().getNorthEast().lng]
94 | }
95 | );
96 | });
97 | });
98 |
99 | describe('clicking an icon', function() {
100 | var layer;
101 | beforeEach(function() {
102 | spyOnEvent(document, 'selectFeature');
103 | this.component.trigger('config', mock.config);
104 | this.component.trigger('data', mock.data);
105 |
106 | waits(25);
107 | runs(function() {
108 | // fake the click event
109 | layer = this.component.layers[mock.data.features[0].id];
110 | layer.fireEvent('click', {
111 | latlng: layer._latlng
112 | });
113 | });
114 | });
115 |
116 | it('sends a selectFeature event', function() {
117 | expect('selectFeature').toHaveBeenTriggeredOnAndWith(
118 | document, layer.feature);
119 | });
120 | });
121 |
122 | describe('selectFeature', function() {
123 | beforeEach(function() {
124 | this.component.trigger('config', mock.config);
125 | this.component.trigger('data', mock.data);
126 | waits(25);
127 | runs(function() {
128 | this.component.trigger(document,
129 | 'selectFeature', mock.data.features[0]);
130 | });
131 | });
132 | it('turns the icon gray', function() {
133 | var icon = this.component.$node.find('.leaflet-marker-icon:first');
134 | expect(icon.attr('src')).toMatch(/marker-icon-gray\.png$/);
135 | });
136 |
137 | it('turns the previously clicked icon back to the default', function() {
138 | this.component.trigger(document, 'selectFeature', null);
139 | var icon = this.component.$node.find('.leaflet-marker-icon:first');
140 | expect(icon.attr('src')).toMatch(/marker-icon\.png$/);
141 | });
142 | });
143 |
144 | describe('deselectFeature', function() {
145 | beforeEach(function() {
146 | this.component.trigger('config', mock.config);
147 | this.component.trigger('data', mock.data);
148 | waits(25);
149 | runs(function() {
150 | this.component.trigger(document,
151 | 'selectFeature', mock.data.features[0]);
152 | });
153 | });
154 | it('turns the icon back to default', function() {
155 | this.component.trigger(document, 'deselectFeature', mock.data.features[0]);
156 | var icon = this.component.$node.find('.leaflet-marker-icon:first');
157 | expect(icon.attr('src')).toMatch(/marker-icon\.png$/);
158 | });
159 | });
160 |
161 | describe("dataSearchResult", function() {
162 | beforeEach(function() {
163 | this.component.trigger('config', mock.config);
164 | this.component.trigger('data', mock.data);
165 | spyOnEvent('.component-root', 'panTo');
166 | this.component.trigger(
167 | document,
168 | 'dataSearchResult',
169 | {
170 | lat: 41,
171 | lng: -71
172 | }
173 | );
174 | });
175 | it('puts a marker on the map', function() {
176 | expect(this.$node.find('.search-result-marker').length).toEqual(1);
177 | });
178 | it('puts the marker at the given lat/lng', function() {
179 | expect(this.component.searchMarker._latlng.lat).toEqual(41);
180 | expect(this.component.searchMarker._latlng.lng).toEqual(-71);
181 | });
182 | it('pans to the lat/long if present', function() {
183 | expect('panTo').toHaveBeenTriggeredOnAndWith(
184 | '.component-root',
185 | {lat: 41,
186 | lng: -71
187 | });
188 | });
189 | });
190 | });
191 | });
192 |
--------------------------------------------------------------------------------
/src/data/facet.js:
--------------------------------------------------------------------------------
1 | define(function(require, exports, module) {
2 | 'use strict';
3 | var flight = require('flight');
4 | var $ = require('jquery');
5 | var _ = require('lodash');
6 | var timedWithObject = require('timed_with_object');
7 | var L = require('leaflet');
8 |
9 | module.exports = flight.component(function facet() {
10 | this.configure = function(ev, config) {
11 | this.config = config.facets;
12 | this.selected = {};
13 | this.facetTitles = {};
14 | this.facetValues = null;
15 | this.facets = null;
16 | };
17 |
18 | this.loadData = function(ev, data) {
19 | this.initializeFacets(data);
20 | };
21 |
22 | this.filterFeatures = function(selected) {
23 | // given a set of selected facets, return a list of IDs that match the
24 | // selected features
25 | var ids;
26 | if (selected) {
27 | ids = _.chain(this.facetValues)
28 | .filter(
29 | function(facetValues) {
30 | return _.all(
31 | selected,
32 | function(selected, facet) {
33 | var type = this.config[facet].type,
34 | property = facetValues[facet];
35 | if (type === 'map') {
36 | if (!this.mapBounds) {
37 | return false;
38 | }
39 | return this.mapBounds.contains(property);
40 | }
41 |
42 | if (!_.isArray(property)) {
43 | property = [property];
44 | }
45 | // calculate the intersection of our selected values and
46 | // the values on the given feature
47 | var intersection = _.intersection(selected, property).length;
48 | if (this.config[facet].type === 'list') {
49 | // must match all of the values
50 | return intersection === selected.length;
51 | } else {
52 | // must match any value
53 | return intersection > 0;
54 | }
55 | },
56 | this);
57 | },
58 | this)
59 | .map('id')
60 | .value();
61 | } else {
62 | ids = _.keys(this.facetValues);
63 | }
64 | return ids;
65 | };
66 |
67 | // returns a map of feature ID to the values for our facets
68 | this.identifyFacetValues = function(data) {
69 | return _.reduce(
70 | data.features,
71 | function(valueMap, feature) {
72 | var values = {
73 | id: feature.id
74 | };
75 | valueMap[feature.id] = values;
76 | _.mapValues(this.config, function(facetConfig, facet) {
77 | if (facetConfig.type === 'map') {
78 | values[facet] = L.latLng(
79 | feature.geometry.coordinates[1],
80 | feature.geometry.coordinates[0]
81 | );
82 | } else {
83 | values[facet] = feature.properties[facet];
84 | }
85 | });
86 | return valueMap;
87 | },
88 | {}, // valueMap
89 | this);
90 | };
91 |
92 | // returns an object with each facet, and a list of facet values
93 | this.identifyFacets = function() {
94 | return _.mapValues(
95 | this.config,
96 | function(facetConfig, facet) {
97 | // adds up the count of the values on the given facet
98 | return _.chain(this.facetValues)
99 | .map(function(values) {
100 | if (facetConfig.type === 'map') {
101 | if (facetConfig.value) {
102 | this.selected[facet] = [facetConfig.text];
103 | }
104 | return [facetConfig.text];
105 | }
106 | return values[facet];
107 | }.bind(this))
108 | .flatten(true)
109 | .uniq()
110 | .sortBy(function(value) { return value.toLowerCase(); })
111 | .value();
112 | }.bind(this));
113 | };
114 |
115 | this.initializeFacets = function(data) {
116 | this.trigger('dataFilteringStarted', {});
117 | this.facetValues = this.identifyFacetValues(data);
118 | this.facets = this.identifyFacets();
119 | this.filterFacets(_.keys(this.facetValues));
120 | };
121 |
122 | this.filterFacets = function(ids) {
123 | var filteredCounts = {},
124 | finished = 0;
125 |
126 | var callback = function() {
127 | finished = finished + 1;
128 | if (finished === _.size(this.config)) {
129 | $(document).trigger('dataFacets', filteredCounts);
130 | this.trigger('dataFilteringFinished', {});
131 | }
132 | }.bind(this);
133 |
134 | _.mapValues(
135 | this.config,
136 | function(facetConfig, facet) {
137 | filteredCounts[facet] = {};
138 | var promise = this.filterSingleFacet(facet, facetConfig, ids);
139 | promise.then(function(counts) {
140 | filteredCounts[facet] = counts;
141 | callback();
142 | });
143 | },
144 | this);
145 | };
146 |
147 | this.filterSingleFacet = function(facet, facetConfig, ids) {
148 | var selectedValues = this.selected[facet] || [];
149 | var featureCount = ids.length;
150 |
151 | return timedWithObject(
152 | this.facets[facet],
153 | function(value, map) {
154 | var selected = _.contains(selectedValues, value),
155 | count;
156 | if (selected) {
157 | // if the value is already selected, then the count is
158 | // just the current count
159 | count = featureCount;
160 | } else {
161 | // otherwise, generate a new selection which includes
162 | // the value
163 | var selectedWithValue = _.clone(this.selected);
164 | selectedWithValue[facet] = _.union(
165 | selectedWithValue[facet],
166 | [value]);
167 | // and and filter the features with the new selection
168 | count = this.filterFeatures(selectedWithValue).length;
169 | // for non-list facets, adding a selection can increase
170 | // the number of features returned, but we actually
171 | // just want to show the number of additional features
172 | // that will be displayed
173 | if (facetConfig.type === 'single' &&
174 | selectedValues.length &&
175 | count >= featureCount) {
176 | count = count - featureCount;
177 | }
178 | }
179 | map.push({value: value,
180 | count: count,
181 | title: this.facetTitles[value],
182 | selected: selected
183 | });
184 | return map;
185 | },
186 | [],
187 | this);
188 | };
189 |
190 | this.filterData = function(ev, params) {
191 | this.trigger('dataFilteringStarted', {});
192 |
193 | window.setTimeout(function() {
194 | if (params) {
195 | var facet = params.facet,
196 | selectedValues = params.selected;
197 |
198 | if (!selectedValues.length) {
199 | delete this.selected[facet];
200 | } else {
201 | this.selected[facet] = selectedValues;
202 | }
203 | }
204 |
205 | var ids = this.filterFeatures(this.selected);
206 |
207 | $(document).trigger('dataFiltered', {
208 | featureIds: ids
209 | });
210 | this.filterFacets(ids);
211 | }.bind(this), 0);
212 | };
213 |
214 | this.onMapBounds = function(ev, data) {
215 | this.mapBounds = L.latLngBounds(
216 | L.latLng(data.southWest),
217 | L.latLng(data.northEast));
218 | if (this.facetValues) {
219 | this.filterData();
220 | }
221 | };
222 |
223 | this.clearFacets = function(ev, params) {
224 | params.selected = [];
225 | this.filterData(ev, params);
226 | };
227 |
228 | this.after('initialize', function() {
229 | this.on(document, 'config', this.configure);
230 | this.on(document, 'data', this.loadData);
231 | this.on(document, 'uiFilterFacet', this.filterData);
232 | this.on(document, 'mapBounds', this.onMapBounds);
233 | this.on(document, 'facetTitles', function(ev, facetTitles) {
234 | this.facetTitles = facetTitles;
235 | });
236 | this.on(document, 'uiClearFacets', this.clearFacets);
237 | });
238 | });
239 | });
240 |
--------------------------------------------------------------------------------
/src/ui/facet.js:
--------------------------------------------------------------------------------
1 | define(function(require, exports, module) {
2 | 'use strict';
3 | // compile all the templates
4 | var flight = require('flight');
5 | var Handlebars = require('handlebars');
6 | var _ = require('lodash');
7 | var $ = require('jquery');
8 | var welcomeTemplate = require('text!templates/welcome.html');
9 | var inputTemplate = require('text!templates/input.html');
10 | var formTemplate = require('text!templates/form.html');
11 | var facetTemplate = require('text!templates/facet.html');
12 | var facetControlsTemplate = require('text!templates/facetControls.html');
13 | var extraResourcesTemplate = require('text!templates/extraResources.html');
14 | var assessmentTemplate = require('text!templates/assessment.html');
15 |
16 | var templates = {
17 | welcome: Handlebars.compile(welcomeTemplate),
18 | input: Handlebars.compile(inputTemplate),
19 | form: Handlebars.compile(formTemplate),
20 | facet: Handlebars.compile(facetTemplate),
21 | facetControls: Handlebars.compile(facetControlsTemplate),
22 | extraResources: Handlebars.compile(extraResourcesTemplate),
23 | assessment: Handlebars.compile(assessmentTemplate)
24 | };
25 |
26 | module.exports = flight.component(function () {
27 | this.configureFacets = function(ev, config) {
28 | this.facetConfig = config.facets;
29 | };
30 |
31 | // -1 is start with intro question that is not a facet
32 | this.facetOffset = -1;
33 |
34 | this.meetsFacetDependency = function(facetData, key, dependency) {
35 | return _.find(facetData, function(facets) {
36 | return _.find(facets, function(facet) {
37 | return facet.value === dependency && facet.selected;
38 | });
39 | });
40 | };
41 |
42 | this.getFacetConfig = function(key, attr) {
43 | if (this.facetConfig[key]) {
44 | return this.facetConfig[key][attr];
45 | }
46 | };
47 |
48 | this.displayFacets = function(ev, facetData) {
49 | // cache facet data so that you can call it internally instead of waiting
50 | // for event from data facet
51 | if (facetData) {
52 | this.facetData = facetData;
53 | } else {
54 | facetData = this.facetData;
55 | }
56 |
57 | // Remove search facets that are not part of the survey
58 | delete facetData.county;
59 |
60 | this.noSelectionsAvailable = false;
61 |
62 | // show first question if you're looking for a treatment facility
63 | if (this.facetOffset === -1) {
64 | this.$node.html(templates.welcome());
65 | this.on('.js-next-prev', 'click', this.nextPrevHandler);
66 | this.on('.js-no-treatment', 'click', this.showNoTreatment);
67 | this.on('.js-not-sure-treatment', 'click', this.showNoTreatment);
68 | return;
69 | }
70 |
71 | var facets = _.keys(facetData);
72 | var key = facets[this.facetOffset];
73 | if (!this.showAllFacets) {
74 | if (this.facetOffset >= facets.length) {
75 | this.$node.find('.js-offer-results[data-offer-results=true]').click();
76 | return;
77 | }
78 |
79 | // does the facet have a dependency?
80 | if (key) {
81 | var dependency = this.getFacetConfig(key, 'dependency');
82 | if (dependency) {
83 | if (!this.meetsFacetDependency(facetData, key, dependency)) {
84 | this.setFacetOffset(this.facetOffset + 1);
85 | return;
86 | }
87 | }
88 | }
89 |
90 | var newFacetData = {};
91 | newFacetData[key] = facetData[key];
92 | facetData = newFacetData;
93 | }
94 |
95 | var facet = _.chain(facetData).map(
96 | _.bind(function(values, key) {
97 | // only one facet available that has no facilities
98 | if (values.length === 1 && values[0].count === 0) {
99 | this.noSelectionsAvailable = true;
100 | }
101 | var hasSelected = _.some(values, 'selected');
102 | var configKey = this.showAllFacets ? 'title' : 'survey_title';
103 | // render a template for each facet
104 | return templates.facet({
105 | title: this.getFacetConfig(key, configKey),
106 | key: key,
107 | // render the form for each value of the facet
108 | form: templates.form({
109 | key: key,
110 | has_selected: hasSelected,
111 | inputs: _.chain(values).filter('count').map(templates.input).
112 | value()
113 | })
114 | });
115 | }, this)).value().join('');
116 |
117 | var previousOffset;
118 | if (typeof this.facetHistory === 'object') {
119 | previousOffset = this.facetHistory[this.facetHistory.length - 1] || -1;
120 | } else {
121 | previousOffset = -1;
122 | }
123 | this.$node.html(
124 | facet +
125 | templates.facetControls({
126 | showResults: this.showAllFacets,
127 | facetOffset: this.facetOffset + 1,
128 | previousFacetOffset: previousOffset
129 | })
130 | );
131 |
132 | this.on('.js-next-prev', 'click', this.nextPrevHandler);
133 | this.on('.js-offer-results', 'click', this.showResultsHandler);
134 |
135 | if (this.noSelectionsAvailable === true) {
136 | // click button to advance to the next facet.
137 | // NOTE(chaserx): I couldn't find a way to use `facetOffset` without
138 | // creating infinite loop.
139 | this.$node.find('button.btn-next').trigger('click');
140 | this.facetHistory.pop();
141 | this.noSelectionsAvailable = false;
142 | return;
143 | }
144 | };
145 |
146 | this.showResultsHandler = function(ev) {
147 | // can be true or false
148 | var offerResults = $(ev.target).data('offerResults');
149 | this.showAllFacets = offerResults;
150 | if (this.showAllFacets) {
151 | this.showResults();
152 | $(document).trigger('uiShowResults', {});
153 | } else {
154 | $('#facets').removeClass('control-sidebar');
155 | $('#facets').addClass('control-survey');
156 | $(document).trigger('uiHideResults', {});
157 | }
158 | this.displayFacets();
159 | };
160 |
161 | this.showResults = function() {
162 | $('#facets').addClass('control-sidebar');
163 | $('#facets').removeClass('control-survey');
164 | };
165 |
166 | this.nextPrevHandler = function(ev) {
167 | if (typeof this.facetHistory === 'undefined') {
168 | this.facetHistory = [];
169 | }
170 | var clickedEl = $(ev.target);
171 | var facility_type = $(ev.target).data('facility-type');
172 | var jump_to_results = $(ev.target).data('jump-to-results');
173 | var offset = parseInt(clickedEl.data('nextFacetOffset'), 10);
174 | if (clickedEl.is('.previous')) {
175 | var facet = _.keys(this.facetData)[offset];
176 | $(document).trigger('uiClearFacets', {facet: facet});
177 | this.setFacetOffset(this.facetHistory.pop());
178 | } else {
179 | if (facility_type) {
180 | $(document).trigger('uiClearFacets', {facet: 'facility_type'});
181 | $(document).trigger('uiClearFacets', {facet: 'out_patient'});
182 | $(document).trigger('uiClearFacets', {facet: 'gender'});
183 | $(document).trigger('uiClearFacets', {facet: 'pregnancy'});
184 | $(document).trigger('uiClearFacets', {facet: 'age'});
185 | $(document).trigger('uiClearFacets', {facet: 'insurance'});
186 | $(document).trigger('uiFilterFacet', {
187 | facet: 'facility_type',
188 | selected: [facility_type]
189 | });
190 | if (facility_type == 'outpatient_offered') {
191 | // outpatient offered settings appear to be injected based on selecting things in the first panel
192 | // so we'll give it 1/10 of a second to do what it needs do and then trigger another jump
193 | var that=this;
194 | window.setTimeout((function(){that.setFacetOffsetWithObject(that)}),100);
195 | }
196 | }
197 | var lastItem = this.facetHistory[this.facetHistory.length - 1];
198 | if (lastItem !== this.facetOffset) {
199 | this.facetHistory.push(this.facetOffset);
200 | }
201 | this.setFacetOffset(offset);
202 | if (jump_to_results) {
203 | $(document).trigger('uiShowResults', {});
204 | }
205 | }
206 | };
207 |
208 | this.setFacetOffset = function(offset) {
209 | this.facetOffset = offset;
210 | this.displayFacets();
211 | };
212 |
213 | this.setFacetOffsetWithObject = function(obj,offset) { obj.setFacetOffset(1); }
214 |
215 | this.clearFacets = function(ev) {
216 | ev.preventDefault();
217 | var facet = $(ev.target).data('facet');
218 | $(document).trigger('uiClearFacets', {facet: facet});
219 | };
220 |
221 | this.selectFacet = function(ev) {
222 | var $form = $(ev.target).parents('form'),
223 | facet = $form.data('facet'),
224 | selected = _.map($form.serializeArray(),
225 | 'name');
226 | window.setTimeout(function() {
227 | $(document).trigger('uiFilterFacet', {
228 | facet: facet,
229 | selected: selected
230 | });
231 | }, 0);
232 | };
233 |
234 | this.showNoTreatment = function() {
235 | this.$node.html(templates.assessment());
236 | };
237 |
238 | // defaultAttrs is now deprecated in favor of 'attributes', but our
239 | // version of flight still uses this.
240 | this.defaultAttrs({
241 | clearFacetsSelector : ".clear-facets"
242 | });
243 |
244 | this.after('initialize', function() {
245 | this.on('change', this.selectFacet);
246 | this.on(document, 'config', this.configureFacets);
247 | this.on(document, 'dataFacets', this.displayFacets);
248 | this.on(document, 'uiShowResults', this.showResults);
249 | this.on(document, 'uiFacetChangeRequest', function(ev, facet) {
250 | var input = $('input[name=' + facet.name + ']');
251 | input.prop('checked', true);
252 | input.trigger('change');
253 | });
254 | this.on('click', { clearFacetsSelector : this.clearFacets });
255 | });
256 | });
257 | });
258 |
--------------------------------------------------------------------------------
/src/ui/map.js:
--------------------------------------------------------------------------------
1 | define(function(require, exports, module) {
2 | 'use strict';
3 | var flight = require('flight');
4 | var L = require('leaflet');
5 | var _ = require('lodash');
6 | var timedWithObject = require('timed_with_object');
7 |
8 | require('L.Control.Locate');
9 | require('leaflet.markercluster');
10 |
11 | module.exports = flight.component(function map() {
12 | this.attributes({
13 | tileUrl: 'http://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png',
14 | tileAttribution: '© OpenStreetMap © CartoDB ',
15 | tileSubdomains: 'abcd',
16 | tileMinZoom: 2,
17 | tileMaxZoom: 19
18 | });
19 |
20 | this.defineIconStyles = function() {
21 | // define icon styles
22 | var path = L.Icon.Default.imagePath;
23 | if (!path) {
24 | path = L.Icon.Default.imagePath = 'lib/leaflet/images';
25 | }
26 | this.grayIcon = L.icon({
27 | iconUrl: path + '/marker-icon-gray.png',
28 | shadowUrl: path + '/marker-shadow.png'
29 | });
30 |
31 | this.defaultIcon = L.icon({
32 | iconUrl: path + '/marker-icon.png',
33 | shadowUrl: path + '/marker-shadow.png'
34 | });
35 | };
36 |
37 | this.configureMap = function(ev, config) {
38 | this.trigger('mapStarted', {});
39 | // if list or facets are enabled, give the map less space
40 | var addition = 0;
41 | if (config.facets) {
42 | addition += 300;
43 | }
44 | if (config.list) {
45 | addition += 300;
46 | }
47 |
48 | if (addition > 0) {
49 | window.setTimeout(function() {
50 | if (this.map) {
51 | this.$node.css('left', '+=' + addition);
52 | this.map.invalidateSize();
53 | }
54 | }.bind(this), 50);
55 | }
56 |
57 | var mapConfig = config.map;
58 |
59 | if (mapConfig.maxZoom){
60 | this.map.options.maxZoom = mapConfig.maxZoom;
61 | this.cluster.options.disableClusteringAtZoom = 1;
62 | this.cluster._maxZoom = mapConfig.maxZoom - 1;
63 | }
64 | if (mapConfig.maxBounds){
65 | this.map.setMaxBounds(mapConfig.maxBounds);
66 | }
67 |
68 | // set feature attribute to be used as preview text to config
69 | this.featurePreviewAttr = config.map.preview_attribute;
70 |
71 | // setup the center after we're done moving around
72 | this.map.setView(mapConfig.center, mapConfig.zoom);
73 | };
74 |
75 | this.loadData = function(ev, data) {
76 | this.defineIconStyles();
77 |
78 | var setupFeature = function(feature, layer) {
79 | this.layers[feature.id] = layer;
80 |
81 | // bind popup to feature with specified preview attribute
82 | this.bindPopupToFeature(
83 | layer,
84 | feature.properties[this.featurePreviewAttr]);
85 |
86 | layer.on({
87 | click: this.emitClick.bind(this),
88 | mouseover: this.emitHover.bind(this),
89 | mouseout: this.clearHover.bind(this)
90 | });
91 | }.bind(this);
92 |
93 | this.layers = {};
94 |
95 | var geojson = L.geoJson(data, {onEachFeature: setupFeature});
96 | geojson.addTo(this.cluster);
97 | };
98 |
99 | this.filterData = function(e, data) {
100 | var object = {
101 | keepLayers: [],
102 | addLayers: [],
103 | removeLayers: []
104 | };
105 | this.trigger('mapFilteringStarted', {});
106 | timedWithObject(
107 | _.pairs(this.layers),
108 | function(pair, object) {
109 | var featureId = pair[0],
110 | layer = pair[1],
111 | selected = _.contains(data.featureIds, featureId),
112 | hasLayer = this.cluster.hasLayer(layer);
113 | if (selected) {
114 | object.keepLayers.push(layer);
115 | }
116 | if (hasLayer && !selected) {
117 | object.removeLayers.push(layer);
118 | } else if (!hasLayer && selected) {
119 | object.addLayers.push(layer);
120 | }
121 | return object;
122 | },
123 | object,
124 | this).then(function(object) {
125 | // rough level at which it's faster to remove all the layers and just
126 | // add the ones we want
127 | if (object.removeLayers.length > 1000) {
128 | this.cluster.clearLayers();
129 | this.cluster.addLayers(object.keepLayers);
130 | } else {
131 | if (object.removeLayers.length) {
132 | this.cluster.removeLayers(object.removeLayers);
133 | }
134 | if (object.addLayers.length) {
135 | this.cluster.addLayers(object.addLayers);
136 | } else {
137 | // add layers will trigger mapFinished, but if we don't add any
138 | // layers then we'll need to do it manually
139 | this.trigger('mapFinished', {});
140 | }
141 | }
142 | }.bind(this));
143 | };
144 |
145 | this.emitClick = function(e) {
146 | this.trigger(document, 'selectFeature', e.target.feature);
147 | };
148 |
149 | this.emitHover = function(e) {
150 | this.trigger(document, 'hoverFeature', e.target.feature);
151 | };
152 |
153 | this.clearHover = function(e) {
154 | this.trigger(document, 'clearHoverFeature', e.target.feature);
155 | };
156 |
157 | this.selectFeature = function(ev, feature) {
158 | if (this.previouslyClicked) {
159 | this.previouslyClicked.setIcon(this.defaultIcon);
160 | this.trigger(document, 'deselectFeature', this.currentFeature);
161 | }
162 | if (feature) {
163 | this.currentFeature = feature;
164 | var layer = this.layers[feature.id];
165 | layer.setIcon(this.grayIcon);
166 | this.previouslyClicked = layer;
167 |
168 | // re-bind popup to feature with specified preview attribute
169 | this.bindPopupToFeature(
170 | layer,
171 | feature.properties[this.featurePreviewAttr]);
172 |
173 | this.trigger('panTo', {lng: feature.geometry.coordinates[0],
174 | lat: feature.geometry.coordinates[1]});
175 | } else {
176 | this.previouslyClicked = null;
177 | }
178 | };
179 |
180 | this.deselectFeature = function(ev, feature) {
181 | if (this.previouslyClicked) {
182 | this.previouslyClicked.setIcon(this.defaultIcon);
183 | }
184 | var layer = this.layers[feature.id];
185 | // re-bind popup to feature with specified preview attribute
186 | this.bindPopupToFeature(
187 | layer,
188 | feature.properties[this.featurePreviewAttr]);
189 | this.previouslyClicked = null;
190 | };
191 |
192 | this.bindPopupToFeature = function(layer, feature){
193 | layer.bindPopup(
194 | feature,
195 | {
196 | closeButton: false,
197 | offset: L.point(0, -40)
198 | });
199 | };
200 |
201 | this.hoverFeature = function(ev, feature) {
202 | if (feature) {
203 | var layer = this.layers[feature.id];
204 | layer.openPopup();
205 | }
206 | };
207 |
208 | this.clearHoverFeature = function(ev, feature) {
209 | if (feature) {
210 | var layer = this.layers[feature.id];
211 | layer.closePopup();
212 | }
213 | };
214 |
215 | this.panTo = function(ev, latlng) {
216 | this.map.panTo(latlng);
217 | };
218 |
219 | this.onSearchResult = function(ev, result) {
220 | if (!this.searchMarker) {
221 | this.searchMarker = L.marker(result, {
222 | icon: L.divIcon({className: 'search-result-marker'})
223 | });
224 | this.searchMarker.addTo(this.map);
225 | } else {
226 | this.searchMarker.setLatLng(result);
227 | }
228 |
229 | this.trigger('panTo',
230 | {lat: result.lat,
231 | lng: result.lng});
232 | };
233 |
234 | this.onBoundsChanged = function onBoundsChanged(e) {
235 | var map = e.target;
236 | var currentBounds = map.getBounds(),
237 | southWest = currentBounds.getSouthWest(),
238 | northEast = currentBounds.getNorthEast();
239 | this.trigger('mapBounds', {
240 | southWest: [southWest.lat, southWest.lng],
241 | northEast: [northEast.lat, northEast.lng]
242 | });
243 | };
244 |
245 | this.after('initialize', function() {
246 | this.on(document, 'uiHideResults', function() {
247 | this.$node.hide();
248 | });
249 |
250 | this.on(document, 'uiShowResults', function() {
251 | this.map._onResize();
252 | this.$node.show();
253 | });
254 |
255 | this.map = L.map(this.node, {});
256 |
257 | this.cluster = new L.MarkerClusterGroup({
258 | chunkedLoading: true,
259 | chunkProgress: function(processed, total) {
260 | if (processed === total) {
261 | this.trigger('mapFinished', {});
262 | }
263 | }.bind(this)
264 | });
265 | this.cluster.addTo(this.map);
266 |
267 | L.control.scale().addTo(this.map);
268 | // Add the location control which will zoom to current
269 | // location
270 | L.control.locate().addTo(this.map);
271 |
272 |
273 | this.layers = {};
274 |
275 | L.tileLayer(this.attr.tileUrl, {
276 | attribution: this.attr.tileAttribution,
277 | subdomains: this.attr.tileSubdomains,
278 | minZoom: this.attr.tileMinZoom,
279 | maxZoom: this.attr.tileMaxZoom
280 | }).addTo(this.map);
281 |
282 | this.map.on('moveend', this.onBoundsChanged.bind(this));
283 |
284 | this.on(document, 'config', this.configureMap);
285 | this.on(document, 'data', this.loadData);
286 | this.on(document, 'dataFiltered', this.filterData);
287 |
288 | this.on(document, 'selectFeature', this.selectFeature);
289 | this.on(document, 'deselectFeature', this.deselectFeature);
290 | this.on(document, 'hoverFeature', this.hoverFeature);
291 | this.on(document, 'clearHoverFeature', this.clearHoverFeature);
292 | this.on(document, 'dataSearchResult', this.onSearchResult);
293 | this.on('panTo', this.panTo);
294 | });
295 |
296 | this.before('teardown', function() {
297 | if (this.map) {
298 | this.map.remove();
299 | this.map = undefined;
300 | }
301 | });
302 | });
303 | });
304 |
--------------------------------------------------------------------------------
/src/templates/welcome.html:
--------------------------------------------------------------------------------
1 | Welcome to GetHelpLex
2 |
3 | GetHelpLex is a resource for people seeking facilities and services for substance use disorder (substance abuse/addiction) in or around Lexington, Kentucky.
4 |
5 |
6 |
7 | What Services Are You Interested In?
8 |
9 |
10 |
11 |
12 |
13 | Assessments
14 |
15 |
16 |
17 |
18 | Medical Detox
19 |
20 |
21 |
22 |
23 | Residential
24 |
25 |
26 |
27 |
28 | Outpatient
29 |
30 |
31 |
32 |
33 |
34 | Twelve Step Programs
35 |
36 |
37 |
38 |
39 |
40 | Guided Search
41 |
42 |
43 |
45 |
46 |
47 |
50 |
51 |
GetHelpLex is a tool to help you find a substance abuse treatment program for yourself or others. It's an informational tool ONLY. If you are experiencing a medical emergency, please call 911.
52 |
53 |
Please remember that it is:
54 |
55 |
56 | NOT a diagnostic tool
57 | NOT an assessment tool
58 | NOT a substitute for substance use treatment services
59 |
60 |
61 |
No identifying information is being collected from your search.
62 |
63 |
64 |
67 |
68 |
69 | The Lexington Needle Exchange Program is located at the Lexington-Fayette County Health Department
70 | (650 Newtown Pike, Lexington, KY.). The exchange is available on Wednesdays from 3:00 p.m. to 6:00 p.m.
71 | and Fridays from 11:00 a.m. to 4:00 p.m. and is anonymous.
72 |
73 |
74 | For additional information, please call 859-252-2371
75 |
76 |
77 |
78 |
81 |
82 |
83 | No cost naloxone is offered each Friday at the Lexington-Fayette County Health Department
84 | (650 Newtown Pike, Lexington, KY.) every Friday from 11:00 a.m. to 4:00 p.m. In order to receive
85 | naloxone a person must bring photo identification and complete a 10 minute class on how to use it.
86 |
87 |
88 | For additional information, please call 859-252-2371
89 |
90 |
91 | Free naloxone is subject to availability
92 |
93 |
94 |
95 |
98 |
99 |
100 | The Matthew Casey Wethington Act for Substance Abuse Intervention is named for Matthew Casey Wethington,
101 | who died in 2002 from a heroin overdose at the age of 23. Casey was an energetic young man who enjoyed
102 | life until it was “taken” by drugs. Casey never intended to become addicted to drugs when he used the first time.
103 | What he did not realize was that his using would progress from abusing to dependence and then to the disease of addiction.
104 | Although his parents tried to get him help, there was no law that could force someone into treatment because he was an
105 | adult. After Casey’s death his parents lobbied for a change. “Casey’s Law” is an involuntary treatment act for
106 | those who suffer from the disease of addiction.
107 |
108 |
109 | For more information on Casey’s Law, please visit the Kentucky Office of Drug Control Policy’s Casey Law page at the link below:
110 | https://odcp.ky.gov/Stop-Overdoses/Pages/Caseys-Law.aspx .
111 |
112 |
113 |
114 |
115 |
116 |
117 | There are many types of substance abuse treatment available.
118 | No single type of treatment is appropriate for all individuals.
119 |
120 |
121 |
122 |
123 | Assessment
124 |
125 | ⇗
126 |
127 |
128 | According to SAMHSA’s Treatment Improvement Protocol Series, No.51, assessment is a process for defining the nature of that problem, determining a diagnosis, and developing specific treatment recommendations for addressing the problem or diagnosis.
129 |
130 |
131 | Medical Detox
132 |
133 | ⇗
134 |
135 |
136 | According to the National Institute on Drug Abuse, medical detox manages the acute physical symptoms of withdrawal associated with stopping drug use.
137 |
138 |
139 | Outpatient
140 |
141 | ⇗
142 |
143 |
144 | Outpatient treatment programs provide treatment at a variety of sites, but the person lives elsewhere. Many meet in the evenings and on weekends so participants can go to school or work. Out-patient treatment programs have different requirements for attendance. Some programs require daily attendance; others meet only one to three times per week (What is Substance Abuse Treatment? A Booklet for Families, SAMHSA).
145 |
146 | Intensive outpatient program (IOP)
147 | Intensive outpatient programs typically meet 3-5 days a week for 2-4 hours a day or more. Relapse prevention is often a major focus of IOP. Typically outpatient programs are often scheduled around work or school.
148 |
149 |
150 | Residential
151 |
152 | ⇗
153 |
154 |
155 | Residential programs provide a living environment with treatment services. Several models of residential treatment (such as the therapeutic community) exist, and treatment in these programs lasts from a month to a year or more, according to SAMHSA’s, “What is Substance Abuse Treatment? A Booklet for Families.
156 |
157 | Medication Assisted Treatment
158 | The text should read: Medications are used in combination with counseling and behavioral therapies to provide a whole-patient approach to the treatment of substance use disorders (SAMHSA).
159 |
160 |
161 |
162 | For additional information in substance abuse treatment, please see the Substance Abuse and Mental Health Services Administration’s
163 |
164 | What is Substance Abuse Treatment? A Booklet for Families.
165 |
166 |
167 |
168 |
169 |
170 |
171 | LFUCG Office of Substance Abuse and Violence Intervention (SAVI) voluntarily provides the data on this website as a service to the public. While SAVI attempts to keep the web information accurate and timely, this information is made available on an “as-is” basis. Please report any revisions or corrections to
gethelplex@lexingtonky.gov or
859-258-3834 . LFUCG SAVI explicitly disclaims any representations and warranties, including but not limited to, sponsoring and/or guaranteeing the services provided by any agency or service provider on this website. If you are experiencing a medical emergency or suicidal, please call
911.
172 |
173 |
174 |
175 |
176 |
177 | To keep our information current, we verify program details once a year . But please verify treatment details with the program before making any treatment decisions.
178 |
179 |
180 |
181 | Please submit feedback or call 859-258-3834 to report troubleshooting problems with the locator. Please note, this feedback is not always monitored and should be used ONLY to report information that needs to be updated and/or revised on the website.
182 |
183 |
184 |
DO NOT send treatment, assessment or referral questions as feedback.
185 |
186 |
187 |
188 |
189 |
--------------------------------------------------------------------------------
/styles/style.css:
--------------------------------------------------------------------------------
1 | @import url("../lib/bootstrap.min.css");
2 | @import url("../lib/leaflet/leaflet.css");
3 | @import url("../lib/leaflet/L.Control.Locate.css");
4 | @import url("../lib/leaflet.markercluster/MarkerCluster.css");
5 | @import url("../lib/leaflet.markercluster/MarkerCluster.Default.css");
6 |
7 | a {
8 | color: #1262b3; /* #428bca */
9 | }
10 |
11 | html {
12 | height: 100%;
13 | }
14 |
15 | body {
16 | height: 100%;
17 | margin: 0;
18 | font-family: "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
19 | font-weight: 300;
20 | display: flex;
21 | flex-direction: column;
22 | overflow: hidden;
23 | color: #111;
24 | }
25 |
26 | body > div.container {
27 | display: flex;
28 | flex-direction: row;
29 | position: relative;
30 | width: 100%;
31 | height: 100%;
32 | margin: 0;
33 | padding: 0;
34 | justify-content: center;
35 | background-color: #f6f6f6;
36 | }
37 |
38 | .left-column, .right-column {
39 | width: 100%;
40 | height: 100%;
41 | overflow: hidden;
42 | }
43 |
44 | .left-column {
45 | width: 600px;
46 | display: flex;
47 | flex-direction: column;
48 | }
49 |
50 | .right-column {
51 | border-left: 1px solid #aaa;
52 | }
53 |
54 | .navbar {
55 | background: #e9e9e9; /*This should be the brand primary color*/
56 | border-bottom: 2px solid #25AAE1; /*This should be the brand accent color*/
57 | margin-bottom: 0;
58 | }
59 |
60 | .navbar .navbar-header {
61 | margin-top: 13px;
62 | margin-left: 0;
63 | }
64 |
65 | .navbar .navbar-header .navbar-toggle {
66 | margin-top: 0px;
67 | }
68 |
69 | .navbar .navbar-brand {
70 | float: none;
71 | vertical-align: middle;
72 | font-weight: 300;
73 | font-size: 27px;
74 | color: #25AAE1;
75 | }
76 |
77 | /* Extra top padding for nav menu - Not Small devices (tablets, 768px and up) */
78 | @media (min-width: 768px) {
79 | #finda-navbar-collapse {
80 | padding-top: 4px;
81 | }
82 | }
83 |
84 | /* Extra bottom margin for nav menu - Small devices (phones, 768px and lower) */
85 | @media (max-width: 767px) {
86 | #finda-navbar-collapse {
87 | margin-bottom: 30px;
88 | }
89 | }
90 |
91 | .navbar-form .btn {
92 | border: none;
93 | outline: none;
94 | background-color: #25AAE1; /*This should be the brand accent color*/
95 | color: #fff;
96 | font-weight: 400;
97 | vertical-align: middle;
98 | }
99 |
100 | .navbar-form .btn:hover {
101 | box-shadow: none;
102 | outline: none;
103 | border: none;
104 | background-color: #4F4175; /*This should be a 25% darken of the brand accent color*/
105 | }
106 |
107 | .navbar-form .btn:active {
108 | box-shadow: none;
109 | outline: none;
110 | border: none;
111 | background-color: #342B4E; /*This should be a 50% darken of the brand accent color*/
112 | }
113 |
114 | .js-offer-results {
115 | padding-left: 1em;
116 | }
117 |
118 | #search-results {
119 | left: 142px;
120 | }
121 |
122 | #search-results .help {
123 | padding: 3px 20px;
124 | }
125 |
126 | #search-results .suggested {
127 | border-top: 1px solid #eee;
128 | color: #ccc;
129 | padding: 6px 20px 3px;
130 | margin-bottom: 0;
131 | }
132 |
133 | #search-results ul {
134 | padding: 0
135 | }
136 |
137 | #search-results li {
138 | padding: 3px 20px;
139 | cursor: pointer;
140 | list-style: none;
141 | }
142 |
143 | #map {
144 | z-index: 1;
145 | }
146 |
147 | .control {
148 | z-index: 2;
149 | font: 14px/16px;
150 | background: #fff;
151 | display: none;
152 | }
153 |
154 | .leaflet-control-locate {
155 | border: 1px solid rgba(0,0,0,0.4)
156 | }
157 |
158 | .leaflet-control-locate a {
159 | background-color: #fff;
160 | background-position: -3px, -2px;
161 | }
162 |
163 | .leaflet-control-locate.active a {
164 | background-position: -33px -2px
165 | }
166 |
167 | .control h4 {
168 | margin: 10px 0;
169 | color: #777;
170 | }
171 |
172 | .search-result-marker {
173 | height: 13px;
174 | width: 13px;
175 | border-radius: 50%;
176 | background-color: #69579c; /*This should be the brand accent color*/
177 | }
178 |
179 | #info {
180 | position: absolute;
181 | border: 1px solid #AFAFAF;
182 | padding: 10px 14px;
183 | height: auto;
184 | width: 275px;
185 | top: 15px;
186 | right: 15px;
187 | overflow-y: auto;
188 | box-shadow: 0 3px 5px rgba(0,0,0,.25);
189 | border-radius: 5px;
190 | }
191 |
192 | .control-sidebar {
193 | float: left;
194 | box-sizing: border-box;
195 | width: 100%;
196 | height: 100%;
197 | overflow-y: auto;
198 | }
199 |
200 | #finda-tabs {
201 | margin-top: 10px;
202 | }
203 |
204 | .tab-content {
205 | height: 100%;
206 | display: flex;
207 | }
208 |
209 | #facets {
210 | padding: 20px;
211 | }
212 |
213 | #facets .checkbox.selected label {
214 | font-weight: bold
215 | }
216 |
217 | #facets form.facet-form {
218 | display: inline-block;
219 | }
220 |
221 | #facets .clear-facets {
222 | display: inline-block;
223 | font-style: italic;
224 | margin: 10px 0 0 3em;
225 | vertical-align: top;
226 | }
227 |
228 | #facets .clear-facets:hover {
229 | color: red; /* TODO: red is probably a horrible color. suggestions? */;;
230 | cursor: pointer;
231 | }
232 |
233 | #listTab.active {
234 | display: flex;
235 | flex-direction: column;
236 | }
237 |
238 | #listTab .list-filters {
239 | display: flex;
240 | flex-direction: row;
241 | padding: 20px 10px;
242 | padding-bottom: 20px;
243 | border-bottom: 1px solid #aaa;
244 | }
245 |
246 | .list-filters .btn-print {
247 | height: 100%;
248 | margin-left: 10px;
249 | }
250 |
251 | #list {
252 | height: 100%;
253 | overflow-y: scroll;
254 | }
255 |
256 | #list .item {
257 | cursor: pointer;
258 | padding: 10px;
259 | border-bottom: 1px solid #aaa;
260 | }
261 |
262 | #list ul {
263 | padding-bottom: 20px;
264 | padding-left: 0;
265 | }
266 |
267 | #list ul ul {
268 | margin: 0 0 0 3em;
269 | padding-bottom: 0;
270 | }
271 |
272 | #list ul li.item > div > div h4 {
273 | margin: 0 0 5px 0;
274 | color: #666;
275 | font-weight: 600;
276 | font-size: 1em;
277 | }
278 |
279 | #list ul li.item > div > div + div {
280 | border-bottom: 1px solid #dedede;
281 | padding: 0.5em 0.3em;
282 | margin-top: 0;
283 | }
284 |
285 | #list ul li.item > div > div + div + div {
286 | border-top: 1px solid #fff;
287 | }
288 |
289 | #list ul li.item > div > div:last-child {
290 | border-bottom: none;
291 | }
292 |
293 | #list ul li.item:nth-child(even) {
294 | background-color: #F1F9FF;
295 | }
296 |
297 | #list.control>ul>li {
298 | border-top: 1px solid #fff;
299 | border-bottom: 1px solid #dedede;
300 | padding: 1em;
301 | cursor: pointer;
302 | border-left: 5px solid #fcfcfc;
303 | list-style: none;
304 | }
305 |
306 | #list .feature-gender ul li,
307 | #list .feature-age ul li {
308 | display: inline;
309 | }
310 |
311 | #list .feature-gender ul,
312 | #list .feature-age ul {
313 | margin-left: 0;
314 | }
315 |
316 | #list .feature-gender ul li + li:before,
317 | #list .feature-age ul li + li:before {
318 | content: "\b7\a0";
319 | }
320 |
321 | /* clear the navbar when anchored to in url */
322 | #list .selected-facility {
323 | border-right: 20px solid #69579c;
324 | }
325 |
326 | #about .modal-footer img {
327 | height: 2.5em;
328 | float: left;
329 | }
330 |
331 | #loading h4 {
332 | text-align: center
333 | }
334 |
335 | .control-survey {
336 | height: 100%;
337 | border-right: 1px solid #aaa;
338 | border-left: 1px solid #aaa;
339 | overflow-y: auto;
340 | width: 100%;
341 | }
342 |
343 | .control-survey .btn {
344 | margin-bottom: 5px
345 | }
346 |
347 | .survey-tabs {
348 | margin: auto;
349 | }
350 |
351 | .nav-tabs {
352 | position: relative;
353 | z-index: 1;
354 | border-bottom: 1px solid #aaa;
355 | }
356 |
357 | .nav-tabs.control-tabs>li.active>a {
358 | background-color: #fff;
359 | border-top: 1px solid #aaa;
360 | border-left: 1px solid #aaa;
361 | border-right: 1px solid #aaa;
362 | }
363 |
364 | .treatment-definition {
365 | margin-top: 1rem;
366 | margin-bottom: 1rem;
367 | }
368 |
369 | .grey-font {
370 | color: #6d6e71;
371 | }
372 |
373 | .mainline {
374 | font-weight: 700 !important;
375 | }
376 |
377 | .tagline {
378 | font-size: 70%;
379 | font-style: italic;
380 | }
381 |
382 | /* proudly borrowed from https://github.com/luckyshot/ga-feedback */
383 | #gaf-button {
384 | position: fixed;
385 | bottom: 0;
386 | right: 1em;
387 | background: rgba(61, 194, 85, 0.8);
388 | color: #fff;
389 | padding: 4px 7px;
390 | font-size: 1.3em;
391 | border-top-left-radius: 5px;
392 | border-top-right-radius: 5px;
393 | z-index: 999999999;
394 | }
395 |
396 | #back-to-top {
397 | text-decoration: none;
398 | position: fixed;
399 | left: 0;
400 | bottom: 0;
401 | display: none;
402 | background: rgba(37, 170, 225, 0.8);
403 | color: #fff;
404 | padding: 4px 7px;
405 | font-size: 1.3em;
406 | border-top-left-radius: 5px;
407 | border-top-right-radius: 5px;
408 | z-index: 999999999;
409 | }
410 |
411 | #feedback-form textarea {
412 | margin-bottom: 20px;
413 | }
414 |
415 | .well {
416 | text-align: center;
417 | }
418 |
419 | .welcome-options {
420 | padding: 0;
421 | list-style-type: none;
422 | margin-right: auto;
423 | margin-left: auto;
424 | }
425 |
426 | /* was 768 - but made it smaller to allow some display on smaller screens */
427 | @media only screen and (max-width : 498px) {
428 | body > div.container {
429 | flex-direction: column;
430 | }
431 |
432 | .left-column {
433 | width: 100%;
434 | }
435 |
436 | .right-column {
437 | display: none !important;
438 | }
439 | }
440 |
441 | @media print {
442 | body.modal-open {
443 | visibility: hidden;
444 | }
445 |
446 | body.modal-open .modal .modal-header,
447 | body.modal-open .modal .modal-body {
448 | visibility: visible;
449 | }
450 |
451 | body.modal-open .modal .modal-header::before, .list-filters::before {
452 | content: "GetHelpLex - gethelplex.org \A";
453 | white-space: pre;
454 | font-weight: 900;
455 | }
456 |
457 | body > div.container {
458 | top: 0;
459 | }
460 |
461 | body, .left-column, #info, .control-sidebar, #list, .control-survey {
462 | overflow: visible !important;
463 | }
464 |
465 | #list {
466 | width: 100%;
467 | border: 0;
468 | }
469 |
470 | #list > ul > li {
471 | border: 0;
472 | padding: 0;
473 | list-style: none !important;
474 | }
475 |
476 | #list ul li.item > div > div + div, #list ul li.item > div > div + div + div {
477 | border: 0;
478 | }
479 |
480 | .right-column, #map, #finda-tabs, #back-to-top, #gaf-button {
481 | display: none !important;
482 | }
483 |
484 | #select_county {
485 | display: none;
486 | }
487 |
488 | .btn-print {
489 | display: none;
490 | }
491 | }
492 |
493 | .btn-service-title {
494 | height:20px;
495 | width:20px;
496 | color:#1262b3;
497 | margin:0px;
498 | margin-left:10px;
499 | margin-bottom:4px;
500 | padding:0px;
501 | padding-bottom:2px;
502 | font-size:18px;
503 | line-height:0px;
504 | }
505 |
506 | .btn-print {
507 | min-height: 34px;
508 | }
509 |
510 | .print-icon {
511 | height:14px;
512 | opacity:0.7;
513 | margin-top:-4px;
514 | }
515 |
--------------------------------------------------------------------------------
/test/spec/data/facet_spec.js:
--------------------------------------------------------------------------------
1 | define(['test/mock', 'lodash'], function(mock, _) {
2 | 'use strict';
3 | describeComponent('data/facet', function() {
4 | beforeEach(function() {
5 | setupComponent();
6 | spyOnEvent(document, 'dataFacets');
7 | spyOnEvent(document, 'dataFiltered');
8 | });
9 |
10 | describe('on config', function() {
11 | beforeEach(function() {
12 | this.component.trigger('config', mock.config);
13 | });
14 | it('records the facets to display', function() {
15 | expect(this.component.config).toEqual(mock.config.facets);
16 | });
17 | });
18 |
19 | describe('on data', function() {
20 | beforeEach(function() {
21 | this.component.trigger('config', mock.config);
22 | this.component.trigger('data', mock.data);
23 | waits(100);
24 | });
25 | it('emits a "dataFacets" event with the values for each facet', function() {
26 | expect('dataFacets').toHaveBeenTriggeredOnAndWith(
27 | document,
28 | {
29 | services_offered: [
30 | {value: 'public education', count: 1, selected: false},
31 | {value: 'social group', count: 2, selected: false},
32 | {value: 'support group', count: 3, selected: false}
33 | ]
34 | });
35 | });
36 | });
37 |
38 | describe('on uiFilterFacet', function() {
39 | describe("single value facet", function() {
40 | beforeEach(function() {
41 | var config = _.clone(mock.config);
42 | config.facets = {
43 | community: {
44 | title: "Community",
45 | type: 'single'
46 | }
47 | };
48 | this.component.trigger('config', config);
49 | this.component.trigger('data', mock.data);
50 | this.component.trigger('uiFilterFacet', {
51 | facet: 'community',
52 | selected: ['Northampton']
53 | });
54 | waits(100);
55 | });
56 | it('emits a "dataFiltered" event with the filtered data', function() {
57 | expect('dataFiltered').toHaveBeenTriggeredOnAndWith(
58 | document,
59 | {
60 | featureIds: [mock.data.features[0].id]
61 | });
62 | });
63 | it('emits a "dataFacets" event with the filtered values for each facet', function() {
64 | expect('dataFacets').toHaveBeenTriggeredOnAndWith(
65 | document,
66 | {community: [
67 | {value: 'Greenfield', count: 1, selected: false},
68 | {value: 'Northampton', count: 1, selected: true},
69 | {value: 'Shellbourne Falls', count: 1, selected: false}
70 | ]
71 | });
72 | });
73 | });
74 | describe("list value facet", function() {
75 | beforeEach(function() {
76 | this.component.trigger('config', mock.config);
77 | this.component.trigger('data', mock.data);
78 | this.component.trigger('uiFilterFacet', {
79 | facet: 'services_offered',
80 | selected: ['social group']
81 | });
82 | waits(100);
83 | });
84 | it('emits a "dataFiltered" event with the filtered data', function() {
85 | expect('dataFiltered').toHaveBeenTriggeredOnAndWith(
86 | document,
87 | {featureIds: [mock.data.features[0].id,
88 | mock.data.features[1].id]
89 | });
90 | });
91 | it('emits a "dataFacets" event with the filtered values for each facet', function() {
92 | expect('dataFacets').toHaveBeenTriggeredOnAndWith(
93 | document,
94 | {services_offered: [
95 | {value: 'public education', count: 0, selected: false},
96 | {value: 'social group', count: 2, selected: true},
97 | {value: 'support group', count: 2, selected: false}
98 | ]
99 | });
100 | });
101 | });
102 | describe("map facet", function() {
103 | describe('value not provided', function() {
104 | beforeEach(function() {
105 | var config = _.clone(mock.config);
106 | config.facets = {
107 | map: {
108 | title: "Map",
109 | text: "Limit results",
110 | type: 'map'
111 | }
112 | };
113 | this.component.trigger('config', config);
114 | this.component.trigger('data', mock.data);
115 | this.component.trigger('mapBounds', {
116 | southWest: [42.3251, -71.6411],
117 | northEast: [42.3250, -72.6412]
118 | });
119 | this.component.trigger('uiFilterFacet', {
120 | facet: 'map',
121 | selected: ['Limit results']
122 | });
123 | waits(50);
124 | });
125 | it('emits a "dataFiltered" event with the filtered data', function() {
126 | expect('dataFiltered').toHaveBeenTriggeredOnAndWith(
127 | document,
128 | {
129 | featureIds: [mock.data.features[0].id]
130 | });
131 | });
132 | it('emits a "dataFacets" event with the filtered values for each facet', function() {
133 | expect('dataFacets').toHaveBeenTriggeredOnAndWith(
134 | document,
135 | {map: [
136 | {value: 'Limit results', count: 1, selected: true}
137 | ]
138 | });
139 | });
140 | });
141 | describe('if value: true is configured', function() {
142 | beforeEach(function() {
143 | var config = _.clone(mock.config);
144 | config.facets = {
145 | map: {
146 | title: "Map",
147 | text: "Limit results",
148 | type: 'map',
149 | value: true
150 | }
151 | };
152 | this.component.trigger('config', config);
153 | this.component.trigger('data', mock.data);
154 | waits(50);
155 | });
156 | it('the facet defaults to selected', function() {
157 | expect('dataFacets').toHaveBeenTriggeredOnAndWith(
158 | document,
159 | {map: [
160 | {value: 'Limit results', count: 3, selected: true}
161 | ]
162 | });
163 | });
164 | });
165 | });
166 | describe("multiple facets", function() {
167 | beforeEach(function() {
168 | var config = _.clone(mock.config);
169 | config.facets.community = {
170 | title: "Community",
171 | type: 'single'
172 | };
173 | this.component.trigger('config', config);
174 | this.component.trigger('data', mock.data);
175 | this.component.trigger('uiFilterFacet', {
176 | facet: 'services_offered',
177 | selected: ['social group']
178 | });
179 | waits(100);
180 | });
181 | it('emits a "dataFiltered" event with the filtered data', function() {
182 | expect('dataFiltered').toHaveBeenTriggeredOnAndWith(
183 | document,
184 | {featureIds: [mock.data.features[0].id,
185 | mock.data.features[1].id]
186 | });
187 | this.component.trigger('uiFilterFacet', {
188 | facet: 'community',
189 | selected: ['Northampton']
190 | });
191 | waits(25);
192 | runs(function() {
193 | expect('dataFiltered').toHaveBeenTriggeredOnAndWith(
194 | document,
195 | {featureIds: [mock.data.features[0].id]
196 | });
197 | });
198 | });
199 | it('emits a "dataFacets" event with the filtered values for each facet', function() {
200 | expect('dataFacets').toHaveBeenTriggeredOnAndWith(
201 | document,
202 | {
203 | community: [
204 | {value: 'Greenfield', count: 1, selected: false},
205 | {value: 'Northampton', count: 1, selected: false},
206 | {value: 'Shellbourne Falls', count: 0, selected: false}
207 | ],
208 | services_offered: [
209 | {value: 'public education', count: 0, selected: false},
210 | {value: 'social group', count: 2, selected: true},
211 | {value: 'support group', count: 2, selected: false}
212 | ]
213 | });
214 | this.component.trigger('uiFilterFacet', {
215 | facet: 'community',
216 | selected: ['Northampton']
217 | });
218 | waits(100);
219 | runs(function() {
220 | expect('dataFacets').toHaveBeenTriggeredOnAndWith(
221 | document,
222 | {
223 | community: [
224 | {value: 'Greenfield', count: 1, selected: false},
225 | {value: 'Northampton', count: 1, selected: true},
226 | {value: 'Shellbourne Falls', count: 0, selected: false}
227 | ],
228 | services_offered: [
229 | {value: 'public education', count: 0, selected: false},
230 | {value: 'social group', count: 1, selected: true},
231 | {value: 'support group', count: 1, selected: false}
232 | ]
233 | });
234 | });
235 | });
236 | it('does not filter away matching simple facets', function() {
237 | this.component.trigger('uiFilterFacet', {
238 | facet: 'services_offered',
239 | selected: ['public education']
240 | });
241 | waits(100);
242 | runs(function() {
243 | expect('dataFacets').toHaveBeenTriggeredOnAndWith(
244 | document,
245 | {
246 | community: [
247 | {value: 'Greenfield', count: 0, selected: false},
248 | {value: 'Northampton', count: 0, selected: false},
249 | {value: 'Shellbourne Falls', count: 1, selected: false}
250 | ],
251 | services_offered: [
252 | {value: 'public education', count: 1, selected: true},
253 | {value: 'social group', count: 0, selected: false},
254 | {value: 'support group', count: 1, selected: false}
255 | ]
256 | });
257 | });
258 | });
259 | });
260 | });
261 | describe('on uiClearFacets', function() {
262 | beforeEach(function() {
263 | this.component.trigger('config', mock.config);
264 | this.component.trigger('data', mock.data);
265 | this.component.trigger('uiFilterFacet', {
266 | facet: 'community',
267 | selected: ['Northampton']
268 | });
269 | });
270 | it('emits a "dataFacets" event with no facets selected', function() {
271 | this.component.trigger('uiClearFacets', {"facet" : "community"});
272 | waits(100);
273 | runs(function() {
274 | expect('dataFacets').toHaveBeenTriggeredOnAndWithFuzzy(
275 | document,
276 | {community: [
277 | {selected: false},
278 | {selected: false},
279 | {selected: false}]
280 | });
281 | });
282 | });
283 | });
284 | });
285 | });
286 |
--------------------------------------------------------------------------------
/lib/leaflet/L.Control.Locate.js:
--------------------------------------------------------------------------------
1 | /*
2 | Copyright (c) 2014 Dominik Moritz
3 |
4 | This file is part of the leaflet locate control. It is licensed under the MIT license.
5 | You can find the project at: https://github.com/domoritz/leaflet-locatecontrol
6 | */
7 | L.Control.Locate = L.Control.extend({
8 | options: {
9 | position: 'topleft',
10 | drawCircle: true,
11 | follow: false, // follow with zoom and pan the user's location
12 | stopFollowingOnDrag: false, // if follow is true, stop following when map is dragged (deprecated)
13 | // range circle
14 | circleStyle: {
15 | color: '#136AEC',
16 | fillColor: '#136AEC',
17 | fillOpacity: 0.15,
18 | weight: 2,
19 | opacity: 0.5
20 | },
21 | // inner marker
22 | markerStyle: {
23 | color: '#136AEC',
24 | fillColor: '#2A93EE',
25 | fillOpacity: 0.7,
26 | weight: 2,
27 | opacity: 0.9,
28 | radius: 5
29 | },
30 | // changes to range circle and inner marker while following
31 | // it is only necessary to provide the things that should change
32 | followCircleStyle: {},
33 | followMarkerStyle: {
34 | //color: '#FFA500',
35 | //fillColor: '#FFB000'
36 | },
37 | circlePadding: [0, 0],
38 | metric: true,
39 | onLocationError: function(err) {
40 | // this event is called in case of any location error
41 | // that is not a time out error.
42 | alert(err.message);
43 | },
44 | onLocationOutsideMapBounds: function(control) {
45 | // this event is repeatedly called when the location changes
46 | control.stopLocate();
47 | alert(context.options.strings.outsideMapBoundsMsg);
48 | },
49 | setView: true, // automatically sets the map view to the user's location
50 | strings: {
51 | title: "Show me where I am",
52 | popup: "You are within {distance} {unit} from this point",
53 | outsideMapBoundsMsg: "You seem located outside the boundaries of the map"
54 | },
55 | locateOptions: {
56 | maxZoom: Infinity,
57 | watch: true // if you overwrite this, visualization cannot be updated
58 | }
59 | },
60 |
61 | onAdd: function (map) {
62 | var container = L.DomUtil.create('div',
63 | 'leaflet-control-locate leaflet-bar leaflet-control');
64 |
65 | var self = this;
66 | this._layer = new L.LayerGroup();
67 | this._layer.addTo(map);
68 | this._event = undefined;
69 |
70 | this._locateOptions = this.options.locateOptions;
71 | L.extend(this._locateOptions, this.options.locateOptions);
72 | L.extend(this._locateOptions, {
73 | setView: false // have to set this to false because we have to
74 | // do setView manually
75 | });
76 |
77 | // extend the follow marker style and circle from the normal style
78 | var tmp = {};
79 | L.extend(tmp, this.options.markerStyle, this.options.followMarkerStyle);
80 | this.options.followMarkerStyle = tmp;
81 | tmp = {};
82 | L.extend(tmp, this.options.circleStyle, this.options.followCircleStyle);
83 | this.options.followCircleStyle = tmp;
84 |
85 | var link = L.DomUtil.create('a', 'leaflet-bar-part leaflet-bar-part-single', container);
86 | link.href = '#';
87 | link.title = this.options.strings.title;
88 |
89 | L.DomEvent
90 | .on(link, 'click', L.DomEvent.stopPropagation)
91 | .on(link, 'click', L.DomEvent.preventDefault)
92 | .on(link, 'click', function() {
93 | if (self._active && (self._event === undefined || map.getBounds().contains(self._event.latlng) || !self.options.setView ||
94 | isOutsideMapBounds())) {
95 | stopLocate();
96 | } else {
97 | locate();
98 | }
99 | })
100 | .on(link, 'dblclick', L.DomEvent.stopPropagation);
101 |
102 | var locate = function () {
103 | if (self.options.setView) {
104 | self._locateOnNextLocationFound = true;
105 | }
106 | if(!self._active) {
107 | map.locate(self._locateOptions);
108 | }
109 | self._active = true;
110 | if (self.options.follow) {
111 | startFollowing();
112 | }
113 | if (!self._event) {
114 | L.DomUtil.addClass(self._container, "requesting");
115 | L.DomUtil.removeClass(self._container, "active");
116 | L.DomUtil.removeClass(self._container, "following");
117 | } else {
118 | visualizeLocation();
119 | }
120 | };
121 |
122 | var onLocationFound = function (e) {
123 | // no need to do anything if the location has not changed
124 | if (self._event &&
125 | (self._event.latlng.lat === e.latlng.lat &&
126 | self._event.latlng.lng === e.latlng.lng &&
127 | self._event.accuracy === e.accuracy)) {
128 | return;
129 | }
130 |
131 | if (!self._active) {
132 | return;
133 | }
134 |
135 | self._event = e;
136 |
137 | if (self.options.follow && self._following) {
138 | self._locateOnNextLocationFound = true;
139 | }
140 |
141 | visualizeLocation();
142 | };
143 |
144 | var startFollowing = function() {
145 | map.fire('startfollowing', self);
146 | self._following = true;
147 | if (self.options.stopFollowingOnDrag) {
148 | map.on('dragstart', stopFollowing);
149 | }
150 | };
151 |
152 | var stopFollowing = function() {
153 | map.fire('stopfollowing', self);
154 | self._following = false;
155 | if (self.options.stopFollowingOnDrag) {
156 | map.off('dragstart', stopFollowing);
157 | }
158 | visualizeLocation();
159 | };
160 |
161 | var isOutsideMapBounds = function () {
162 | if (self._event === undefined)
163 | return false;
164 | return map.options.maxBounds &&
165 | !map.options.maxBounds.contains(self._event.latlng);
166 | };
167 |
168 | var visualizeLocation = function() {
169 | if (self._event.accuracy === undefined)
170 | self._event.accuracy = 0;
171 |
172 | var radius = self._event.accuracy;
173 | if (self._locateOnNextLocationFound) {
174 | if (isOutsideMapBounds()) {
175 | self.options.onLocationOutsideMapBounds(self);
176 | } else {
177 | map.fitBounds(self._event.bounds, {
178 | padding: self.options.circlePadding,
179 | maxZoom: self._locateOptions.maxZoom
180 | });
181 | }
182 | self._locateOnNextLocationFound = false;
183 | }
184 |
185 | // circle with the radius of the location's accuracy
186 | var style, o;
187 | if (self.options.drawCircle) {
188 | if (self._following) {
189 | style = self.options.followCircleStyle;
190 | } else {
191 | style = self.options.circleStyle;
192 | }
193 |
194 | if (!self._circle) {
195 | self._circle = L.circle(self._event.latlng, radius, style)
196 | .addTo(self._layer);
197 | } else {
198 | self._circle.setLatLng(self._event.latlng).setRadius(radius);
199 | for (o in style) {
200 | self._circle.options[o] = style[o];
201 | }
202 | }
203 | }
204 |
205 | var distance, unit;
206 | if (self.options.metric) {
207 | distance = radius.toFixed(0);
208 | unit = "meters";
209 | } else {
210 | distance = (radius * 3.2808399).toFixed(0);
211 | unit = "feet";
212 | }
213 |
214 | // small inner marker
215 | var mStyle;
216 | if (self._following) {
217 | mStyle = self.options.followMarkerStyle;
218 | } else {
219 | mStyle = self.options.markerStyle;
220 | }
221 |
222 | var t = self.options.strings.popup;
223 | if (!self._circleMarker) {
224 | self._circleMarker = L.circleMarker(self._event.latlng, mStyle)
225 | .bindPopup(L.Util.template(t, {distance: distance, unit: unit}))
226 | .addTo(self._layer);
227 | } else {
228 | self._circleMarker.setLatLng(self._event.latlng)
229 | .bindPopup(L.Util.template(t, {distance: distance, unit: unit}))
230 | ._popup.setLatLng(self._event.latlng);
231 | for (o in mStyle) {
232 | self._circleMarker.options[o] = mStyle[o];
233 | }
234 | }
235 |
236 | if (!self._container)
237 | return;
238 | if (self._following) {
239 | L.DomUtil.removeClass(self._container, "requesting");
240 | L.DomUtil.addClass(self._container, "active");
241 | L.DomUtil.addClass(self._container, "following");
242 | } else {
243 | L.DomUtil.removeClass(self._container, "requesting");
244 | L.DomUtil.addClass(self._container, "active");
245 | L.DomUtil.removeClass(self._container, "following");
246 | }
247 | };
248 |
249 | var resetVariables = function() {
250 | self._active = false;
251 | self._locateOnNextLocationFound = self.options.setView;
252 | self._following = false;
253 | };
254 |
255 | resetVariables();
256 |
257 | var stopLocate = function() {
258 | map.stopLocate();
259 | map.off('dragstart', stopFollowing);
260 |
261 | L.DomUtil.removeClass(self._container, "requesting");
262 | L.DomUtil.removeClass(self._container, "active");
263 | L.DomUtil.removeClass(self._container, "following");
264 | resetVariables();
265 |
266 | self._layer.clearLayers();
267 | self._circleMarker = undefined;
268 | self._circle = undefined;
269 | };
270 |
271 | var onLocationError = function (err) {
272 | // ignore time out error if the location is watched
273 | if (err.code == 3 && this._locateOptions.watch) {
274 | return;
275 | }
276 |
277 | stopLocate();
278 | self.options.onLocationError(err);
279 | };
280 |
281 | // event hooks
282 | map.on('locationfound', onLocationFound, self);
283 | map.on('locationerror', onLocationError, self);
284 |
285 | // make locate functions available to outside world
286 | this.locate = locate;
287 | this.stopLocate = stopLocate;
288 | this.stopFollowing = stopFollowing;
289 |
290 | return container;
291 | }
292 | });
293 |
294 | L.Map.addInitHook(function () {
295 | if (this.options.locateControl) {
296 | this.locateControl = L.control.locate();
297 | this.addControl(this.locateControl);
298 | }
299 | });
300 |
301 | L.control.locate = function (options) {
302 | return new L.Control.Locate(options);
303 | };
304 |
--------------------------------------------------------------------------------