Platinum Partner
ria,blazeds,flex & air,livecycle data services,data visualization

Real-time data visualization using Flex and publish/subscribe messaging

When you hear the term “data visualization”, you probably immediately think of pie charts, bar charts, and line charts showing sales data, population data, or other historical data.   These charts and graphs help us to visualize the data, detect key trends, and make decisions moving forward.

While charts are great for understanding historical data, real-time data can benefit from visualization also. For example, financial transaction data can contain immediately useful trending information that is undetectable without some visualization.  The same goes for credit card transactions, website traffic, etc.

Although Flex is fantastic for traditional data visualization, it is uniquely qualified for visualizing real-time data.  This article describes some common techniques for collecting data in real-time, visualizing it using traditional charting controls, and applying some more advanced location/mapping controls to further improve the user experience. 

Each of the exercises covered in this article includes complete source code. There is also an extensive list of resources at the end of the article to help with server-side configuration and provide additional background.

  Editor's Note: This article was co-authored by Greg Wilson, Enterprise Platform Evangelist at Adobe Systems.

Collecting the data

The first step in being able to visualize real-time data is collecting it.  Whether it’s financial transactions or GPS coordinates of a moving vehicle, you’ll need to collect the data as it is generated.  One popular (yet inefficient) way of doing this is to poll a data source repeatedly.  Although it may work for some scenarios, it is not at all scalable.  Imagine 100,000 customers refreshing a webpage every minute to get a current snapshot of their stock portfolio!  A much more efficient approach is to implement publish/subscribe messaging.  Your real-time data source “publishes” the data as a series of messages and individual “subscribers” consume the data.  Whether there is one subscriber or a million subscribers, the publishing side simply broadcasts the data.

Flex offers two main components for this model:  Producer and Consumer.   These components need a server-side message broker to work.  This is where BlazeDS and LiveCycle Data Services come in. 

 

A simple chat application

Before you get into the visualization fun, building a simple chat app will help you understand the underlying communication mechanism on which real-time visualization in Flex depends. This obligatory “hello world” application will illustrate the publish/subscribe paradigm that enables efficient real-time data collection.

Note: Exercise 1 requires access to a working BlazeDS or LiveCycle Data Services deployment; the remaining exercises do not. If you’re not running BlazeDS or LiveCycle Data Services and don’t want to install one of them at this time, you can simply review the code in Exercise 1, and get hands-on with the data visualization exercises.

In this exercise you will learn how to build a simple chat application. You’ll code Flex Producer and Consumer objects that will communicate via a ChannelSet object. The ChannelSet object defines at runtime which type of channel will be used in communicating with the BlazeDS or LiveCycle Data Services server. The channels are defined in the order in which they should be used in the case of a failure. Your Consumer and Producer objects will contain a destination property pointing to a destination defined on your BlazeDS or LiveCycle Data Services server along with the message and fault handlers shown in the code sample below. The subscribe() method is called on the Consumer object to start listening to any incoming messages across the channel that the Producer may be sending. Consumed messages are shown in a TextArea when they’re received. The send() method on the Producer object is called to write messages out to the destination where those messages are then broadcast to any Consumers  currently subscribed.  

Note: You must add the destination to your messaging-config.xml file (see Appendix) on the BlazeDS/LiveCycle Data Services instance and restart your server for this to work correctly.

 

Complete Source Code:

<?xml version="1.0" encoding="utf-8"?>
<mx:Application xmlns:mx="http://www.adobe.com/2006/mxml" applicationComplete="init()">

<!--How to send a message to a topic as a producer of the message. Should see message sent on big screen.
Need to have a destination setup in messaging config called helloWorld under lcds-samples/WEB-INF/flex. -->

<mx:Script>
<![CDATA[
import mx.messaging.events.MessageEvent;
import mx.messaging.messages.AsyncMessage;
import mx.controls.Alert;

private function init():void
{
// Start listening for messages published in the "helloWorld" destination
consumer.subscribe();
}
private function messageHandler(event:MessageEvent):void
{
log.text += event.message.body.chatMessage + "\n";
}
// Add a send method to send the message as the producer
private function send():void
{
var message:AsyncMessage = new AsyncMessage();
// Set the term chatMessage for the body
message.body.chatMessage = msg.text;
producer.send(message);
msg.text = "";
}
]]>
</mx:Script>

<mx:ChannelSet id="channelSet">
<mx:AMFChannel url="http://your-lcds-blazeds-host:your-port/lcds-samples/messagebroker/amflongpolling"/>
</mx:ChannelSet>

<mx:Consumer id="consumer"
channelSet="{channelSet}"
destination="helloWorld"
message="messageHandler(event)"
fault="Alert.show(event.faultString)"/>

<!-- Add a producer to send messages -->
<mx:Producer id="producer"
channelSet="{channelSet}"
destination="helloWorld"/>

<mx:Panel title="Data Feed" width="100%" height="100%">
<mx:TextArea id="log" width="100%" height="100%" editable="false"/>
<mx:ControlBar>
<mx:TextInput id="msg" width="100%" enter="send()"/>
<mx:Button label="Send" click="send()"/>
</mx:ControlBar>
</mx:Panel>

</mx:Application>

 

See the Results:

 

You can now share this application with anyone that has access to the same server and chat with them.  

 

Consuming real-time data without visualization

If you are already a Flex developer, you are probably familiar with Tour de Flex, a tool developed by the Adobe evangelist team for exploring coding samples.  Tour de Flex includes more than 360 samples, which illustrate every Flex component as well as Adobe AIR and tons of third-party components.  You can download Tour de Flex from http://flex.org/tour.

More than a million samples are viewed every month in Tour de Flex and we track every single view.  Not only do we record each view, we publish this data to a message destination that you can connect to!

In this example you’ll learn how to consume the Tour de Flex real-time data feed. As in the previous sample, you will define your Producer, Consumer and ChannelSet objects but this time you will point to the Tour de Flex data feed destination (tdf.sampleviewingfeed).  Since there are other types of data feeds currently collected on the server you need to specify a subtopic of “Flex” to point to the Tour de Flex live data. When the data is received it is simply added to an ArrayCollection and shown as a new entry in the DataGrid.

 

Note: The destination can be accessed using four different channels: RTMP, AMF Streaming, AMF Long Polling, and AMF Polling. You can choose which channel(s) to use to access the destination. For example, if you don't want a dependency on fds.swc, do not use the RTMP channel definition. The order of the channels defined in the ChannelSet tag is important. The system will try to connect using the first one. If that connection fails, it will fall back to the next one, and so on. In production applications, these URLs shouldn't be hardcoded in the application. One alternative is to use a configuration file as described here:

http://coenraets.org/blog/2009/03/externalizing-service-configuration-using-blazeds-and-lcds/

For more information on defining and using channels, see the following link:

http://devgirl.wordpress.com/2009/07/14/livecycle-data-services-channels-and-endpoints-explained/        

 

Complete Source Code:

<?xml version="1.0" encoding="utf-8"?>
<mx:Application xmlns:mx="http://www.adobe.com/2006/mxml" layout="absolute" applicationComplete="init()">

<mx:Script>
<![CDATA[
import mx.messaging.events.MessageEvent;
import mx.collections.ArrayCollection;
import mx.controls.Alert;

[Bindable]
private var feedItems:ArrayCollection = new ArrayCollection();

private function init():void
{
this.consumer.subscribe();
}
private function messageHandler(event:MessageEvent):void
{
feedItems.addItem(event.message.body);
}

]]>
</mx:Script>

<!-- The destination can be accessed using four different channels: RTMP, AMF Streaming, AMF Long Polling, AMF Polling.
You can choose which channel(s) to use to access the destination: just comment out the channels you don't want to use.
For example, if you don't want a dependency on fds.swc, uncomment the RTMP channel definition. The order of the channels
defined in the ChannelSet tag is important: The system will try to connect using the first one. If that connection fails,
it will fall back to the next one, etc. In real life, these URLs shouldn't be hardcoded in the application... One option
is to use a configuration file as described here:
http://coenraets.org/blog/2009/03/externalizing-service-configuration-using-blazeds-and-lcds/-->
<mx:ChannelSet id="channelSet">
<!-- RTMP channel -->
<!--<mx:RTMPChannel id="rtmp" url="rtmp://tourdeflex.adobe.com:2037"/>-->
<!-- Long Polling Channel -->
<mx:AMFChannel url="http://tourdeflex.adobe.com:8080/lcds-samples/messagebroker/amflongpolling"/>
<!-- Regular polling channel -->
<mx:AMFChannel url="http://tourdeflex.adobe.com:8080/lcds-samples/messagebroker/amfpolling"/>
</mx:ChannelSet>

<mx:Consumer id="consumer"
channelSet="{channelSet}"
destination="tdf.sampleviewingfeed"
subtopic="flex"
message="messageHandler(event)"
fault="Alert.show(event.faultString)"/>

<mx:Panel title="Data Feed" width="100%" height="100%">
<mx:DataGrid id="dg" dataProvider="{feedItems}" width="100%" height="100%"/>
</mx:Panel>

</mx:Application>

 

See the results:

 The end result should look something like the following, where each time a user accesses a Tour de Flex sample, you see it displayed in the DataGrid:

It’s not really pretty, but it’s cool to see real-time data appearing!

 

Using some simple charts with real-time data

In this exercise, you’ll replace the DataGrid with some charting controls so you can visualize the data in real-time.  If you are running Flex Builder 3 Standard edition, you will not be able to use the charting controls as they are only included in Flex Builder 3 Pro.  However, the code for Exercise 4 will work just fine.

The messaging code in this sample is the same as the previous exercise using the Producer, Consumer and ChannelSet objects with the Tour de Flex real-time data feed. The difference in this one is that the message handler must filter out duplicate countries to accurately display the data in the charting controls.  This is done through an array filtering method shown in the sample code below and a Dictionary object that acts as a hashmap for tracking the unique country code and holding its corresponding value object with the incremented number of hits. Three types of charts are shown here: a bar chart, line chart, and pie chart. You could create many other types of charts using the same dataProvider instance and other Flex charts, IBM ILog Elixir, Axiis, or other data visualization components.

 

Complete Source Code:

<?xml version="1.0" encoding="utf-8"?>
<mx:Application xmlns:mx="http://www.adobe.com/2006/mxml" layout="absolute" applicationComplete="init()"
viewSourceURL="srcview/index.html">

<mx:Script>
<![CDATA[
import mx.collections.ArrayCollection;
import mx.messaging.events.MessageEvent;
import mx.controls.Alert;

[Bindable]
private var feedItems:ArrayCollection = new ArrayCollection();

private var keys:Dictionary = new Dictionary();
private var countryArray:Array = new Array();

private function init():void
{
this.consumer.subscribe();
}
/**
* This filter function is used to see if the country item in the array has already had hits.
* If it has then we need to accumulate the hit count, otherwise we need to add it to the
* data provider with hit count 1.
**/
private function accumulateHits(item:Object, idx:uint, arr:Array):Boolean
{
// 'keys' will contain each country code as the key and the item object (CountryVO) as the value, which
// contains both country and hits amount.
if (keys[item.country]!=null) {
// Update the hits count for the item
item.hits = item.hits+=1;
return false;
} else {
item.hits=1;
keys[item.country] = item;
feedItems.addItem(item);
return true;
}
}

private function messageHandler(event:MessageEvent):void
{
var country:String = event.message.body.country;
var co:CountryVO = new CountryVO();
co.country=country;
// Add this to an array and call a filter function to check if that country has already
// had hits detected.
countryArray.push(co);
countryArray.filter(accumulateHits);
}
]]>
</mx:Script>

<!-- The destination can be accessed using four different channels: RTMP, AMF Streaming, AMF Long Polling, AMF Polling.
You can choose which channel(s) to use to access the destination: just comment out the channels you don't want to use.
For example, if you don't want a dependency on fds.swc, uncomment the RTMP channel definition. The order of the channels
defined in the ChannelSet tag is important: The system will try to connect using the first one. If that connection fails,
it will fall back to the next one, etc. In real life, these URLs shouldn't be hardcoded in the application... One option
is to use a configuration file as described here:
http://coenraets.org/blog/2009/03/externalizing-service-configuration-using-blazeds-and-lcds/-->
<mx:ChannelSet id="channelSet">
<!-- RTMP channel -->
<!--<mx:RTMPChannel id="rtmp" url="rtmp://tourdeflex.adobe.com:2037"/>-->
<!-- Long Polling Channel -->
<mx:AMFChannel url="http://tourdeflex.adobe.com:8080/lcds-samples/messagebroker/amflongpolling"/>
<!-- Regular polling channel -->
<mx:AMFChannel url="http://tourdeflex.adobe.com:8080/lcds-samples/messagebroker/amfpolling"/>
</mx:ChannelSet>

<mx:Consumer id="consumer"
channelSet="{channelSet}"
destination="tdf.sampleviewingfeed"
subtopic="flex"
message="messageHandler(event)"
fault="Alert.show(event.faultString)"/>

<mx:VBox width="100%" height="100%">
<mx:Legend dataProvider="{bar}"/>
<mx:BarChart id="bar" height="70%" showDataTips="true" dataProvider="{feedItems}">
<mx:horizontalAxis>
<mx:CategoryAxis categoryField="country"/>
</mx:horizontalAxis>

<mx:series>
<mx:BarSeries xField="country" yField="hits" displayName="Hits per Country"/>
</mx:series>
</mx:BarChart>

<mx:HBox>
<mx:ColumnChart id="col" showDataTips="true" dataProvider="{feedItems}">
<mx:horizontalAxis>
<mx:CategoryAxis categoryField="country"/>
</mx:horizontalAxis>

<mx:series>
<mx:ColumnSeries xField="country" yField="hits"/>
</mx:series>
</mx:ColumnChart>

<mx:Legend dataProvider="{pie}" />
<mx:PieChart id="pie" showDataTips="true" dataProvider="{feedItems}">
<mx:series>
<mx:PieSeries field="hits" nameField="country" labelPosition="insideWithCallout"/>
</mx:series>
</mx:PieChart>
</mx:HBox>
</mx:VBox>
</mx:Application>

 

/* CountryVO.as */
package
{
[Bindable]
public class CountryVO
{
public var country:String;
public var hits:Number;
}
}

 

See the results:

When you run the code you should see your charts; as Tour de Flex hits are generated by users around the world the three charts will be updated. It will look something like this:


 

Visualization with Mapping

In this exercise you will use the Google Maps Flash API with the real-time Tour de Flex Data to learn how to use a mapping API for real-time data visualization. Once again you will use the same Flex Messaging objects as before pointing to the same Tour de Flex data feed. Now you’ll simply build in the Google Maps API as shown in the completed source code below.

 

Note: You must include the Google Maps SWCs for this to work correctly.

You can get the SWCs from http://code.google.com/apis/maps/documentation/flash/

 

Complete Source Code:

<?xml version="1.0" encoding="utf-8"?>
<mx:Application xmlns:mx="http://www.adobe.com/2006/mxml" xmlns:maps="com.google.maps.*" layout="absolute" viewSourceURL="srcview/index.html">

<mx:Script>
<![CDATA[
import mx.controls.Alert;
import com.google.maps.Map3D;
import com.google.maps.LatLng;
import com.google.maps.MapEvent;
import com.google.maps.overlays.Marker;
import com.google.maps.MapType;
import com.google.maps.MapOptions;

import mx.messaging.messages.IMessage;
import com.google.maps.controls.MapTypeControl;
import com.google.maps.controls.NavigationControl;

import com.google.maps.View;
import com.google.maps.geom.Attitude;

import com.google.maps.InfoWindowOptions;

private var flyToFlag:Boolean = false;
private var infoWindowFlag:Boolean = false;
private var flightSpeed:Number = 3;
private var initialZoom:Number = 2.5;
private var flyToZoom:Number = 5;
private var initialLat:Number = 25;
private var initialLong:Number = 10;

private var initialAttitude:Attitude = new Attitude(0,0,0);
private var flyToAttitude:Attitude = new Attitude(20,30,0);

private function onMapPreinitialize(event:MapEvent):void
{
var myMapOptions:MapOptions = new MapOptions();
myMapOptions.zoom = initialZoom;
myMapOptions.center = new LatLng(initialLat, initialLong);
myMapOptions.mapType = MapType.PHYSICAL_MAP_TYPE;
myMapOptions.viewMode = View.VIEWMODE_PERSPECTIVE;
myMapOptions.attitude = initialAttitude;
map.setInitOptions(myMapOptions);
}

private function onMapReady():void
{
map.addControl(new MapTypeControl());
map.addControl(new NavigationControl());
// The map is good to go, let's subscribe to the messages
consumer.subscribe();
}

private function toggleFlyTo():void
{
if(flyToFlag)
{
map.cancelFlyTo(); // Stop any current flyTo motion
map.flyTo(new LatLng(initialLat, initialLong), initialZoom, initialAttitude, 5);
}
flyToFlag = flyToCheckBox.selected;
}

private function toggleInfoWindow():void
{
infoWindowFlag = infoWindowCheckBox.selected;
}

private function messageHandler(message:IMessage):void
{
var latlng:LatLng = new LatLng(message.body.latitude, message.body.longitude);

if(latlng != null)
{
var marker:Marker = new Marker(latlng);
if(flyToFlag) map.flyTo(latlng, flyToZoom, flyToAttitude, flightSpeed);
if(infoWindowFlag) map.openInfoWindow(latlng, new InfoWindowOptions({title: message.body.city, content: "Sample ID:" + message.body.sampleId + " viewed"}));
map.addOverlay(marker);
}
}
]]>
</mx:Script>

<mx:ChannelSet id="channelSet">
<!-- RTMP channel -->
<!--<mx:RTMPChannel id="rtmp" url="rtmp://tourdeflex.adobe.com:2037"/>-->
<!-- Long Polling Channel -->
<mx:AMFChannel url="http://tourdeflex.adobe.com:8080/lcds-samples/messagebroker/amflongpolling"/>
<!-- Regular polling channel -->
<mx:AMFChannel url="http://tourdeflex.adobe.com:8080/lcds-samples/messagebroker/amfpolling"/>
</mx:ChannelSet>

<mx:Consumer id="consumer"
channelSet="{channelSet}"
destination="tdf.sampleviewingfeed"
subtopic="flex"
message="messageHandler(event.message)"
fault="Alert.show(event.faultString)"/>

<maps:Map3D id="map" mapevent_mappreinitialize="onMapPreinitialize(event)"
mapevent_mapready="onMapReady()"
width="100%" height="100%" key=""/> <!-- API key not needed when running from localhost -->

<mx:VBox x="100" y="5">
<mx:Label fontSize="18" fontWeight="bold" text="Tour de Flex Live Traffic" right="60" />
<mx:CheckBox id="flyToCheckBox" fontWeight="bold" label="FlyTo (animated 3D flight)" click="toggleFlyTo()"/>
<mx:CheckBox id="infoWindowCheckBox" fontWeight="bold" label="Info Window" click="toggleInfoWindow()"/>
</mx:VBox>

</mx:Application>


See the results:

Make sure you include the Google Maps SWCs in your project and then test your code. You should start seeing markers added to your map as people access Tour de Flex. It will look something like this:

Try checking the FlyTo and Info Window checkboxes to see some other cool stuff!


Other examples

For a more complete data visualization application using the Tour de Flex data, check out http://www.adobe.com/devnet/flex/tourdeflex/planetary_dashboard/ . This example introduces a heatmap control, an IBM ILOG Elixir component available at http://www-01.ibm.com/software/integration/visualization/elixir/, along with a couple of data grids.   The complete source is available by right-clicking in the application and selecting "View source".  A link is provided in the resources below to the server-side code for the Tour de Flex data feed.


In October 2009 at Adobe MAX, FedEx demonstrated a tracking application for their "Custom Critical" service that uses real-time data from devices on their trucks, including GPS receivers and temperature sensors. To build this application, FedEx used the same technologies described in this article.  

Other potential use cases for this type of data visualization include flight tracking, financial transactions, credit card transaction fraud detection, network traffic analysis, web activity analysis, real-time voting/polling and many more.


Conclusion

We've only scratched the surface on what's possible when combining publish/subscribe messaging with Flex data visualization.  Hopefully this gives you enough knowledge and confidence to start creating your own applications.  The Appendix includes a Resource Links section, which identifies several key resources including blog posts describing server requirements, configuration, and more.

 

Appendix

The messaging-config.xml file contains the destination definition used in the first exercise.

<destination id="helloWorld">
<properties>
<network>
<session-timeout>0</session-timeout>
</network>
<server>
<max-cache-size>1000</max-cache-size>
<message-time-to-live>0</message-time-to-live>
<durable>false</durable>
</server>
</properties>
<channels>
<channel ref="my-longpolling-amf"/>
</channels>
</destination>

Note: the <channels> tag can be omitted if default channels have been configured to use long polling.

The services-config.xml file contains the channel definition for long-polling in this instance and looks like this:

<channel-definition id="my-longpolling-amf"
class="mx.messaging.channels.AMFChannel">

<endpoint url="http://{server.name}:{server.port}/
{context.root}/messagebroker/amflongpolling"
class="flex.messaging.endpoints.AMFEndpoint"/>

<properties>
<polling-enabled>true</polling-enabled>
<polling-interval-seconds>5</polling-interval-seconds>
<wait-interval-millis>60000</wait-interval-millis>
<client-wait-interval-millis>1</client-wait-interval-millis>
<max-waiting-poll-requests>100</max-waiting-poll-requests>
</properties>
</channel-definition>

 

The exercises that access the Tour de Flex data use the Tour de Flex messaging destination. The messaging-config.xml file destination definition for it is shown below:

<destination id="tdf.sampleviewingfeed">
<properties>
<network>
<session-timeout>0</session-timeout>
</network>
<server>
<max-cache-size>1000</max-cache-size>
<message-time-to-live>0</message-time-to-live>
<durable>true</durable>
<allow-subtopics>true</allow-subtopics>
<subtopic-separator>.</subtopic-separator>
</server>
</properties>
<channels>
<channel ref="my-rtmp"/>
<channel ref="my-longpolling-amf"/>
<channel ref="my-polling-amf"/>
</channels>
</destination>

 


Additional Notes

If latency is a concern and you are running LiveCycle Data Services, your best option is to use an RTMP channel. To do so you must include the fds.swc library in your Flex project build path. It can be found in the lcds/WEB-INF/flex/libs directory of LiveCycle Data Services. See the following blog post for more details about choosing a channel:                 

http://devgirl.wordpress.com/2009/07/14/livecycle-data-services-channels-and-endpoints-explained/

 

Resource Links

  1. Tour de Flex: http://flex.org/tourdeflex
  2. Real-time Dashboard: http://www.adobe.com/devnet/flex/tourdeflex/planetary_dashboard/
  3. Real-time Dashboard Server Side: http://coenraets.org/blog/2009/05/tdfdashboard
  4. BlazeDS Home Page: http://opensource.adobe.com
  5. LiveCycle Data Services: http://www.adobe.com/products/livecycle/dataservices/
  6. IBM ILOG Elixir: http://www.ilog.com/products/ilogelixir/
  7. Google Maps API: http://code.google.com/apis/maps/documentation/flash
  8. Securing Messages – Building a secure adapter http://www.jamesward.com/blog/2009/07/22/protected-messaging-in-flex-with-blazeds-and-lcds
  9. LiveCycle Data Services Quick Starts: http://help.adobe.com/en_US/LiveCycleDataServicesES/3.0/QuickStarts/lcds3_quickstarts.html

 Greg Wilson:
http://GregsRamblings.com      
http://twitter.com/GregoryWilson

Holly Schinsky:
http://devgirl.wordpress.com
http://twitter.com/DevGirlFL

 

About the Authors

Holly Schinsky

Holly Schinsky is a senior software engineer with 13 years of experience in development, including Java/J2EE, Flex/AIR and ActionScript as her primary focus. She was one of the authors of the Tour de Flex AIR and web apps, and wrote the Tour de Flex Eclipse plug-in for Adobe. She has worked on various Adobe projects over the years, including an XForms to XFA conversion tool for the Adobe LiveCycle Designer application. She has an extensive background in server-side programming, including being part of the small development team that wrote the workflow engine originally behind LiveCycle ES. In recent years, Holly has become heavily involved in the Flex community, including working on the Attest Flex/AIR Certification study AIR app in addition to continuing to support Tour de Flex.

Greg Wilson

Greg is a senior technical evangelist focused on the use of xFlex, AIR, ColdFusion and LiveCycle in enterprise applications. Greg has over 20 years experience architecting and developing large-scale enterprise applications spanning multiple technologies. Greg joined Adobe in 2004 through the acquisition of Q-Link Technologies, a company that he founded.

 

 

{{ tag }}, {{tag}},

{{ parent.title || parent.header.title}}

{{ parent.tldr }}

{{ parent.urlSource.name }}
{{ parent.authors[0].realName || parent.author}}

{{ parent.authors[0].tagline || parent.tagline }}

{{ parent.views }} ViewsClicks
Tweet

{{parent.nComments}}