Weather Report Guide (Simple)
This guide walks you through building a simple weather report widget. It takes about 25 minutes to complete. It was written with CanJS 4.1.
The final widget looks like:
To use the widget:
- Enter a location (example: Chicago)
- If the location name isn’t unique, click on the intended location.
- See the 10-day forecast for your selected city.
Start this tutorial by cloning the following JS Bin:
This JS Bin has initial prototype HTML and CSS which is useful for getting the application to look right.
The following sections are broken down into:
- Problem — A description of what the section is trying to accomplish.
- Things to know — Information about CanJS that is useful for solving the problem.
- Solution — The solution to the problem.
Setup
The problem
Get the basic setup for a CanJS app (in a JS Bin) setup by:
- Creating a template that outputs the pre-constructed HTML.
- Defining a
WeatherViewModel
constructor function. - Rendering the template with an instance of
WeatherViewModel
. - Inserting the result of the rendered template into the page.
Things to know
A can-stache template can be loaded from a
<script>
tag with can.stache.from and used to render data into a document fragment:const template = can.stache.from(SCRIPT_ID); const fragment = template({message: "Hello World"}); // fragment -> <h1>Hello World</h1>
can.DefineMap can be used to define the behavior of observable objects like:
const Type = can.DefineMap.extend({ message: "string" });
Instances of these can.DefineMap types are often used as a ViewModel that controls the behavior of a can-stache template (or can-component).
const MessageViewModel = can.DefineMap.extend({ message: "string" }); const messageVM = new MessageViewModel(); const fragment = template(messageVM)
The solution
Update the HTML tab to wrap the template in a script
tag:
<script id="app-template" type="text/stache">
<div class="weather-widget">
<div class="location-entry">
<label for="location">Enter Your location:</label>
<input id="location" type="text"/>
</div>
<p class="loading-message">
Loading places…
</p>
<div class="location-options">
<label>Pick your place:</label>
<ul>
<li>Some Place</li>
<li>Another Place</li>
</ul>
</div>
<div class="forecast">
<h1>10-day Chicago Weather Forecast</h1>
<ul>
<li>
<span class="date">Today</span>
<span class='description scattered-showers'>Scattered Showers</span>
<span class="high-temp">100<sup>°</sup></span>
<span class="low-temp">-10<sup>°</sup></span>
</li>
</ul>
</div>
</div>
</script>
Update the JavaScript tab to:
- Define a ViewModel.
- Create an instance of the ViewModel .
- Load the
app-template
template. - Render the template with the ViewModel instance.
- Insert the rendered result into the page.
const WeatherViewModel = can.DefineMap.extend({
});
const vm = new WeatherViewModel();
const template = can.stache.from("app-template");
const fragment = template( vm );
document.body.appendChild(fragment);
Allow a user to enter a location
The problem
We want an input
element to:
- Allow a person to type a location to search for weather.
- Show the user the location they typed.
Things to know
There are many ways to define a property on a
DefineMap
. The simplest way ispropName: "<TYPE>"
like:DefineMap.extend({ property: "string" })
The toParent:to can set an input’s
value
to a ViewModel property like:<input value:to="property" />
A can-stache template uses {{key}} magic tags to insert data into the HTML output like:
{{property}}
The solution
Update the JavaScript tab to define a location
property as a string.
const WeatherViewModel = can.DefineMap.extend({
location: "string"
});
const vm = new WeatherViewModel();
const template = can.stache.from("app-template");
const fragment = template( vm );
document.body.appendChild(fragment);
Update the template in the HTML tab to:
- Update
location
on the ViewModel when the input changes. - Show value of the ViewModel’s
location
property.
<script id="app-template" type="text/stache">
<div class="weather-widget">
<div class="location-entry">
<label for="location">Enter Your location:</label>
<input id="location" value:to="location" type="text" />
</div>
<p class="loading-message">
Loading places…
</p>
<div class="location-options">
<label>Pick your place:</label>
<ul>
<li>Some Place</li>
<li>Another Place</li>
</ul>
</div>
<div class="forecast">
<h1>10-day {{location}} Weather Forecast</h1>
<ul>
<li>
<span class="date">Today</span>
<span class='description scattered-showers'>Scattered Showers</span>
<span class="high-temp">100<sup>°</sup></span>
<span class="low-temp">-10<sup>°</sup></span>
</li>
</ul>
</div>
</div>
</script>
Get and display the places for the user’s location name
The problem
Once the user has entered a location name, we need to get which “place” it is. For example, a user might enter Paris, but we don’t know if they mean the Paris in France or the one in Illinois. We need to get a list of matching places for the location name and display the matching places on the page.
Things to know
ES5 Getter Syntax can be used to define a
DefineMap
property that changes when another property changes. For example, the following defines anexcitedMessage
property that always has a!
after themessage
property:DefineMap.extend({ message: "string", get excitedMessage() { return this.message+"!"; } });
YQL provides a service endpoint for retrieving a list of places that match some text. For example, the following requests all places that match
Paris
:https://query.yahooapis.com/v1/public/yql? format=json& q=select * from geo.places where text="Paris"
The list of matched places will be in the response data’s
data.query.results.place
property. If there is only a single match,place
will be an object instead of an array.The fetch API is an easy way to make requests to a URL and get back JSON. Use it like:
fetch(url).then(function(response) { return response.json(); }).then(function(data) { });
can.param is able to convert an object into a query string format like:
can.param({format: "json", q: "select"}) //-> "format=json&q=select"
Use {{#if(value)}} to do
if/else
branching incan-stache
.Use {{#each(value)}} to do looping in
can-stache
.Promise
s are observable in can-stache. Given a promisesomePromise
, you can:- Check if the promise is loading like:
{{#if(somePromise.isPending)}}
. - Loop through the resolved value of the promise like:
{{#each(somePromise.value)}}
.
- Check if the promise is loading like:
The solution
- Show a “Loading places…” message while we wait on data.
- Once the places are resolved, list each place’s name, state, country and type.
Update the template in the HTML tab to:
<script id="app-template" type="text/stache">
<div class="weather-widget">
<div class="location-entry">
<label for="location">Enter Your location:</label>
<input id="location" value:to="location" type="text" />
</div>
{{#if(placesPromise.isPending)}}
<p class="loading-message">
Loading places…
</p>
{{/if}}
{{#if(placesPromise.isResolved)}}
<div class="location-options">
<label>Pick your place:</label>
<ul>
{{#each(placesPromise.value)}}
<li>
{{name}}, {{admin1.content}}, {{country.code}} ({{placeTypeName.content}})
</li>
{{/each}}
</ul>
</div>
{{/if}}
<div class="forecast">
<h1>10-day {{location}} Weather Forecast</h1>
<ul>
<li>
<span class="date">Today</span>
<span class='description scattered-showers'>Scattered Showers</span>
<span class="high-temp">100<sup>°</sup></span>
<span class="low-temp">-10<sup>°</sup></span>
</li>
</ul>
</div>
</div>
</script>
Update the JavaScript tab to:
- Define a
placesPromise
property that will represent the loading places. - If the user has typed in at least two characters, we fetch the matching places.
- If only a single place is returned, we still convert it into an array so the data stays consistent.
const yqlURL = "//query.yahooapis.com/v1/public/yql?";
const WeatherViewModel = can.DefineMap.extend({
location: "string",
get placesPromise() {
if (this.location && this.location.length > 2) {
return fetch(
yqlURL +
can.param({
q: 'select * from geo.places where text="'+this.location+'"',
format: "json"
})
).then(function(response) {
return response.json();
}).then(function(data) {
console.log(data);
if (Array.isArray(data.query.results.place)) {
return data.query.results.place;
} else {
return [data.query.results.place];
}
});
}
}
});
const vm = new WeatherViewModel();
const template = can.stache.from("app-template");
const fragment = template( vm );
document.body.appendChild(fragment);
Allow a user to select a place
The problem
When a user clicks on a place, we need to indicate their selection.
Things to know
Use (on:EVENT) to listen to an event on an element and call a method in
can-stache
. For example, the following callssayHi()
when the<div>
is clicked.<div on:click="sayHi()"> … </div>
this
in a stache template refers to the current context of a template or section.For example, the
this
inthis.name
refers to thecontext
object:const template = stache("{{this.name}}"); const context = {name: "Justin"}; template(context);
Or, when looping through a list of items,
this
refers to each item:{{#each(items)}} <li>{{this.name}}</li> <!-- this is each item in items --> {{/each}}
The “any” type can be used to define a property as accepting any data type like:
const MessageViewModel = can.DefineMap.extend({ message: "string", metaData: "any" })
can.DefineMap
can also have methods:const MessageViewModel = can.DefineMap.extend({ message: "string", metaData: "any", sayHi: function() { this.message = "Hello"; } });
The solution
Update the template in the HTML tab to:
- When a
<li>
is clicked on, callpickPlace
with the correspondingplace
. - When a
place
has been set, write out the forecast header.
<script id="app-template" type="text/stache">
<div class="weather-widget">
<div class="location-entry">
<label for="location">Enter Your location:</label>
<input id="location" value:to="location" type="text" />
</div>
{{#if(placesPromise.isPending)}}
<p class="loading-message">
Loading places…
</p>
{{/if}}
{{#if(placesPromise.isResolved)}}
<div class="location-options">
<label>Pick your place:</label>
<ul>
{{#each(placesPromise.value)}}
<li on:click="../pickPlace(this)">
{{name}}, {{admin1.content}}, {{country.code}} ({{placeTypeName.content}})
</li>
{{/each}}
</ul>
</div>
{{/if}}
{{#if(place)}}
<div class="forecast">
<h1>10-day {{place.name}} Weather Forecast</h1>
<ul>
<li>
<span class="date">Today</span>
<span class='description scattered-showers'>Scattered Showers</span>
<span class="high-temp">100<sup>°</sup></span>
<span class="low-temp">-10<sup>°</sup></span>
</li>
</ul>
</div>
{{/if}}
</div>
</script>
Update the JavaScript tab to:
- Define a
place
property as taking any data type. - Define a
pickPlace
method that sets the place property.
const yqlURL = "//query.yahooapis.com/v1/public/yql?";
const WeatherViewModel = can.DefineMap.extend({
location: "string",
get placesPromise() {
if (this.location && this.location.length > 2) {
return fetch(
yqlURL+
can.param({
q: 'select * from geo.places where text="'+this.location+'"',
format: "json"
})
).then(function(response) {
return response.json();
}).then(function(data) {
console.log(data);
if (Array.isArray(data.query.results.place)) {
return data.query.results.place;
} else {
return [data.query.results.place];
}
});
}
},
place: "any",
pickPlace: function(place) {
this.place = place;
}
});
const vm = new WeatherViewModel();
const template = can.stache.from("app-template");
const fragment = template( vm );
document.body.appendChild(fragment);
Get and display the forecast
The problem
Once we’ve selected a place, we need to get and display the forecast data for the selected place.
Things to know
ViewModel methods can be called within a can-stache template like:
{{myMethod(someValue)}}
YQL provides a service endpoint for retrieving a forecast that matches a
place
’swoeid
. For example, the following requests the forecast for Paris, France’swoeid
:https://query.yahooapis.com/v1/public/yql? format=json& q=select * from weather.forecast where woeid=615702
The stylesheet includes icons for classNames that match:
sunny
,mostly-cloudy
,scattered-thunderstorms
, etc.
The solution
Update the template in the HTML tab to:
- Display each forecast day’s details (date, text, high, and low).
- Use the
toClassName
method to convert the forecast’stext
into aclassName
value that will be matched by the stylesheet.
<script id="app-template" type="text/stache">
<div class="weather-widget">
<div class="location-entry">
<label for="location">Enter Your location:</label>
<input id="location" value:to="location" type="text" />
</div>
{{#if(placesPromise.isPending)}}
<p class="loading-message">
Loading places…
</p>
{{/if}}
{{#if(placesPromise.isResolved)}}
<div class="location-options">
<label>Pick your place:</label>
<ul>
{{#each(placesPromise.value)}}
<li on:click="../pickPlace(this)">
{{name}}, {{admin1.content}}, {{country.code}} ({{placeTypeName.content}})
</li>
{{/each}}
</ul>
</div>
{{/if}}
{{#if(place)}}
<div class="forecast">
<h1>10-day {{place.name}} Weather Forecast</h1>
<ul>
{{#each(forecastPromise.value)}}
<li>
<span class="date">{{date}}</span>
<span class="description {{toClassName(text)}}">{{text}}</span>
<span class="high-temp">{{high}}<sup>°</sup></span>
<span class="low-temp">{{low}}<sup>°</sup></span>
</li>
{{/each}}
</ul>
</div>
{{/if}}
</div>
</script>
Update the JavaScript tab to:
- Define a
forecastPromise
property that gets a list of promises. - Define a
toClassName
method that lowercases and hyphenates any text passed in.
const yqlURL = "//query.yahooapis.com/v1/public/yql?";
const WeatherViewModel = can.DefineMap.extend({
location: "string",
get placesPromise() {
if (this.location && this.location.length > 2) {
return fetch(
yqlURL+
can.param({
q: 'select * from geo.places where text="'+this.location+'"',
format: "json"
})
).then(function(response) {
return response.json();
}).then(function(data) {
console.log(data);
if (Array.isArray(data.query.results.place)) {
return data.query.results.place;
} else {
return [data.query.results.place];
}
});
}
},
place: "any",
pickPlace: function(place) {
this.place = place;
},
get forecastPromise() {
if ( this.place ) {
return fetch(
yqlURL+
can.param({
q: 'select * from weather.forecast where woeid='+this.place.woeid,
format: "json"
})
).then(function(response) {
return response.json();
}).then(function(data) {
console.log("forecast data", data);
const forecast = data.query.results.channel.item.forecast;
return forecast;
});
}
},
toClassName: function(text) {
return text.toLowerCase().replace(/ /g, "-");
}
});
const vm = new WeatherViewModel();
const template = can.stache.from("app-template");
const fragment = template( vm );
document.body.appendChild(fragment);
Hide the forecast if the user changes the entered location
The problem
Currently, if the user changes the entered location, the weather forecast for the other city is still visible. Let’s hide it!
Things to know
DefineMap
setter's can be used to add behavior when a property is set like:const MessageViewModel = can.DefineMap.extend({ message: { type: "string", set: function() { this.metaData = null; } }, metaData: "any", });
The solution
Update the JavaScript tab to set the place
property to null when the location
changes.
const yqlURL = "//query.yahooapis.com/v1/public/yql?";
const WeatherViewModel = can.DefineMap.extend({
location: {
type: "string",
set: function() {
this.place = null;
}
},
get placesPromise() {
if (this.location && this.location.length > 2) {
return fetch(
yqlURL+
can.param({
q: 'select * from geo.places where text="'+this.location+'"',
format: "json"
})
).then(function(response) {
return response.json();
}).then(function(data) {
console.log(data);
if (Array.isArray(data.query.results.place)) {
return data.query.results.place;
} else {
return [data.query.results.place];
}
});
}
},
place: "any",
pickPlace: function(place) {
this.place = place;
},
get forecastPromise() {
if ( this.place ) {
return fetch(
yqlURL+
can.param({
q: 'select * from weather.forecast where woeid='+this.place.woeid,
format: "json"
})
).then(function(response) {
return response.json();
}).then(function(data) {
console.log("forecast data", data);
const forecast = data.query.results.channel.item.forecast;
return forecast;
});
}
},
toClassName: function(text) {
return text.toLowerCase().replace(/ /g, "-");
}
});
const vm = new WeatherViewModel();
const template = can.stache.from("app-template");
const fragment = template( vm );
document.body.appendChild(fragment);
Skip selecting a place if only one place matches the entered location
The problem
If a single place is returned for the entered location, we can skip asking the user to select their place; instead, we should show the forecast immediately.
Things to know
can.DefineMap
getters are passed their last set value. This way, the property can be derived from either the set value or other properties.const MessageVM = can.DefineMap.extend({ username: "string", message: { get: function(lastSet) { if (lastSet) { return lastSet; } else { return "Hello "+this.username; } } } }); const messageVM = new MessageVM({username: "Hank"}); messageVM.message //-> "Hello Hank"; messageVM.message = "Welcome to Earth"; messageVM.message //-> "Welcome to Earth"
Use asynchronous getters to derive data from asynchronous sources. For example:
const MessageVM = can.DefineMap.extend({ messageId: "string", message: { get: function(lastSet, resolve) { fetch("/message/"+this.messageId) .then(function(response) { return response.json(); }).then(resolve); } } });
The solution
Update the template in the HTML tab to use a showPlacePicker
property to determine if we should show the place
picker list.
<script id="app-template" type="text/stache">
<div class="weather-widget">
<div class="location-entry">
<label for="location">Enter Your location:</label>
<input id="location" value:to="location" type="text" />
</div>
{{#if(placesPromise.isPending)}}
<p class="loading-message">
Loading places…
</p>
{{/if}}
{{#if(placesPromise.isResolved)}}
{{#if(showPlacePicker)}}
<div class="location-options">
<label>Pick your place:</label>
<ul>
{{#each(placesPromise.value)}}
<li on:click="../pickPlace(this)">
{{name}}, {{admin1.content}}, {{country.code}} ({{placeTypeName.content}})
</li>
{{/each}}
</ul>
</div>
{{/if}}
{{/if}}
{{#if(place)}}
<div class="forecast">
<h1>10-day {{place.name}} Weather Forecast</h1>
<ul>
{{#each(forecastPromise.value)}}
<li>
<span class="date">{{date}}</span>
<span class="description {{toClassName(text)}}">{{text}}</span>
<span class="high-temp">{{high}}<sup>°</sup></span>
<span class="low-temp">{{low}}<sup>°</sup></span>
</li>
{{/each}}
</ul>
</div>
{{/if}}
</div>
</script>
Update the JavaScript tab to:
- Define a
places
property that will have the places list returned by theYQL
service. - Define a
showPlacePicker
property that is true if there’s more than one place inplaces
and theplace
property hasn’t been set yet. - Update the
place
property to default to the first item inplaces
if there is only one item.
const yqlURL = "//query.yahooapis.com/v1/public/yql?";
const WeatherViewModel = can.DefineMap.extend({
location: {
type: "string",
set: function() {
this.place = null;
}
},
get placesPromise() {
if (this.location && this.location.length > 2) {
return fetch(
yqlURL+
can.param({
q: 'select * from geo.places where text="'+this.location+'"',
format: "json"
})
).then(function(response) {
return response.json();
}).then(function(data) {
console.log(data);
if (Array.isArray(data.query.results.place)) {
return data.query.results.place;
} else {
return [data.query.results.place];
}
});
}
},
places: {
get: function(lastSet, resolve) {
if (this.placesPromise) {
this.placesPromise.then(resolve)
}
}
},
get showPlacePicker() {
return !this.place && this.places && this.places.length > 1;
},
place: {
type: "any",
get: function(lastSet) {
if (lastSet) {
return lastSet;
} else {
if (this.places && this.places.length === 1) {
return this.places[0];
}
}
}
},
pickPlace: function(place) {
this.place = place;
},
get forecastPromise() {
if ( this.place ) {
return fetch(
yqlURL+
can.param({
q: 'select * from weather.forecast where woeid='+this.place.woeid,
format: "json"
})
).then(function(response) {
return response.json();
}).then(function(data) {
console.log("forecast data", data);
const forecast = data.query.results.channel.item.forecast;
return forecast;
});
}
},
toClassName: function(text) {
return text.toLowerCase().replace(/ /g, "-");
}
});
const vm = new WeatherViewModel();
const template = can.stache.from("app-template");
const fragment = template( vm );
document.body.appendChild(fragment);
Result
When finished, you should see something like the following JS Bin: