first of: I am really having a problem with JFreechart and mainly I really believe that this is my fault because I start using the library without fully understanding how it fully function or use
second of: these are some helpful resource that helped me :
check it out 1
check it out 2
check it out 3
my current state : my problem is in making use of the drawPrimaryLine()
in my already build project so I am still having a problem in connecting the dots
in my way, not in a sequence way
example: if I enter (10,10) and (15,15) and (20,20) and (25,25) in this sequence, this is what I will end up with (the left side without connecting, the right side with connecting)
My problem is:
1 - when drawing a line is showing on the right side, I don't want the line to be generated or created until all of the points are add and the button done has been clicked *show in the most bottom right side
2 - I don't want the showing line to be in a sequence way I want the line to be shown base on some algorithm and not all dots will have or a line will pass through it, only a line will pass in some case.
so, in summary: not all dots will be connected only some based on an algorithm.
this is my code :
public class x_y_2 extends JFrame {
private static final String title = "Connecting The Dots";
private XYSeries added = new XYSeries("Added");
public List ls = new LinkedList<XYSeries>();
private XYLineAndShapeRenderer renderer = new XYLineAndShapeRenderer();
final XYSeriesCollection dataset = new XYSeriesCollection();
private XYPlot plot ;
public x_y_2(String s) {
super(s);
final ChartPanel chartPanel = createDemoPanel();
this.add(chartPanel, BorderLayout.CENTER);
JPanel control = new JPanel();
JLabel label = new JLabel("Enter 'x' value");
JTextField Field_x = new JTextField();
Field_x.setPreferredSize( new Dimension( 100, 24 ));
JLabel label2 = new JLabel("Enter 'y' value");
JTextField Field_y = new JTextField();
JLabel error = new JLabel("Importent*** in case no value is entered,value is set to '1' ");
error.setForeground(Color.RED);
Field_y.setPreferredSize( new Dimension( 100, 24 ));
control.add(label);
control.add(Field_x);
control.add(label2);
control.add(Field_y);
control.add(new JButton(new AbstractAction("Add") {
#Override
public void actionPerformed(ActionEvent e) {
if (Field_x.getText().isEmpty()) {
Field_x.setText("1"); ;
}
if (Field_y.getText().isEmpty()) {
Field_y.setText("1");
}
Double x = Double.parseDouble(Field_x.getText());
Double y = Double.parseDouble(Field_y.getText());
added.add(x,y);
ls.add(added);
Field_x.setText("");
Field_y.setText("");
}
}));
control.add(error);
control.add(new JButton(new AbstractAction("Done..") {
#Override
public void actionPerformed(ActionEvent e) {
label.setVisible(false);
label2.setVisible(false);
Field_x.setVisible(false);
Field_y.setVisible(false);
error.setVisible(false);
PrimaryLine pr = new PrimaryLine(3);
GraphingData graphingdata = new GraphingData(2,4,2,10);
// pr.drawPrimaryLine(state, g2, plot, dataset, pass, series, item, domainAxis, rangeAxis, dataArea);
System.out.println(ls.size());
for (int i = 0 ; i < ls.size() ; i++) {
XYSeries xy = (XYSeries)ls.get(i);
System.out.println(xy.getX(i) +" "+xy.getY(i));
}
}
}));
this.add(control, BorderLayout.SOUTH);
}
private XYDataset createSampleData() {
dataset.addSeries(added);
return dataset;
}
private ChartPanel createDemoPanel() {
JFreeChart jfreechart = ChartFactory.createXYLineChart(
title, "X", "Y", createSampleData(),PlotOrientation.VERTICAL, true, true, false);
plot =jfreechart.getXYPlot();
renderer.setSeriesLinesVisible(0, true);
renderer.setSeriesShapesVisible(0, true);
plot.setRenderer(renderer);
return new ChartPanel(jfreechart);
}}
second class :
public class GraphingData extends JPanel {
double x_st , y_st , x_ed, y_ed = 0;
public Graphics2D g2 ;
public GraphingData(double x_st,double y_st,double x_ed,double y_ed) {
this.x_st = x_st ;
this.y_st = y_st;
this.x_ed = x_ed;
this.y_ed = y_ed;
}
protected void paintComponent(Graphics g) {
super.paintComponent(g);
g2 = (Graphics2D)g;
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING,RenderingHints.VALUE_ANTIALIAS_ON);
g2.draw(new Line2D.Double(x_st,y_st,x_ed, y_ed));
}
}
Third Class :
public class PrimaryLine extends XYLineAndShapeRenderer {
private final int anchor;
public PrimaryLine(int acnchor) {
this.anchor = acnchor;
}
#Override
protected void drawPrimaryLine(XYItemRendererState state, Graphics2D g2,
XYPlot plot, XYDataset dataset, int pass, int series, int item,
ValueAxis domainAxis, ValueAxis rangeAxis, Rectangle2D dataArea) {
if (item == anchor) {
return;
}
}
public void chart() {
PrimaryLine r = new PrimaryLine(8);
XYPlot plot = new XYPlot(createSampleData(),new NumberAxis("X"), new
NumberAxis("Y"), r);
JFreeChart chart = new JFreeChart(plot);
}
private XYDataset createSampleData() {
XYSeriesCollection xySeriesCollection = new XYSeriesCollection();
XYSeries added = new XYSeries("added");
added.add(4,2);
added.add(2,1);
xySeriesCollection.addSeries(added);
return xySeriesCollection;
}
}
Any kinda of help I would be greatfull for .
Related
I have a Jfreechart which is plotting some mock real-time data. When I have my domain axis set to auto, the data can be seen updating every second. However, I wish to plot the data over a wider range (say the whole day). When I change the range, I am then unable to see the plot unless I zoom in.
Once zoomed in, the line does not cover the whole graph, but only a portion. This line then moves across the graph instead of growing/drawing along it
/** #see http://stackoverflow.com/questions/5048852 */
public class DTSCTest extends ApplicationFrame {
private static final String TITLE = "Dynamic Series";
private static final String START = "Start";
private static final String STOP = "Stop";
private static final float MINMAX = 100;
private static final int COUNT = 10;
private static final int FAST = 1000;
private static final int SLOW = FAST * 5;
private static final Random random = new Random();
private double gateStart = ThreadLocalRandom.current().nextInt(0, 101);
private boolean returning = false;
private Timer timer;
public DTSCTest(final String title) {
super(title);
final DynamicTimeSeriesCollection dataset =
new DynamicTimeSeriesCollection(1, COUNT, new Second());
Date date = new Date();
dataset.setTimeBase(new Second(date));
float[] gateStartLoad = new float[1];
gateStartLoad[0] = (float)gateStart;
dataset.addSeries(gateStartLoad, 0, "Longwall Data");
JFreeChart chart = createChart(dataset);
final JComboBox combo = new JComboBox();
combo.addItem("Fast");
combo.addItem("Slow");
combo.addActionListener(new ActionListener() {
#Override
public void actionPerformed(ActionEvent e) {
if ("Fast".equals(combo.getSelectedItem())) {
timer.setDelay(FAST);
} else {
timer.setDelay(SLOW);
}
}
});
this.add(new ChartPanel(chart), BorderLayout.CENTER);
JPanel btnPanel = new JPanel(new FlowLayout());
btnPanel.add(combo);
this.add(btnPanel, BorderLayout.SOUTH);
timer = new Timer(FAST, new ActionListener() {
float[] newData = new float[1];
#Override
public void actionPerformed(ActionEvent e) {
if(gateStart == 100){
returning = true;
}else if(gateStart == 0){
returning = false;
}
if(returning){
gateStart--;
}else{
gateStart++;
}
newData[0] = (float)gateStart;
dataset.advanceTime();
System.out.println(dataset.getNewestTime());
dataset.appendData(newData);
}
});
}
private JFreeChart createChart(final XYDataset dataset) {
final JFreeChart result = ChartFactory.createTimeSeriesChart(
TITLE, "hh:mm:ss", "Shearer Position", dataset, true, true, false);
final XYPlot plot = result.getXYPlot();
DateAxis domain = (DateAxis)plot.getDomainAxis();
Calendar calendar = Calendar.getInstance();
calendar.set(2021, 0, 6);
System.out.println(new Date());
System.out.println(calendar.getTime());
domain.setRange(new Date(), calendar.getTime());
domain.setDateFormatOverride(new SimpleDateFormat("HH:mm:ss"));
ValueAxis range = plot.getRangeAxis();
range.setRange(0, 100);
return result;
}
public void start() {
timer.start();
}
public static void main(final String[] args) {
EventQueue.invokeLater(new Runnable() {
#Override
public void run() {
DTSCTest demo = new DTSCTest(TITLE);
demo.pack();
RefineryUtilities.centerFrameOnScreen(demo);
demo.setVisible(true);
demo.start();
}
});
}
}
How do I make it so that the line is continuous (shows every observed point of data in the series), and how do I make it visible when I manually set the range
I removed domain.setRange(new Date(), calendar.getTime()); and changed nMoments in my DynamicTimeSeriesCollection(1, COUNT, new Second());, making COUNT a multiple of 60 and increasing its value. My x-axis now properly shows time however the plot is not continuous, it disappears after a time
This question already has an answer here:
Making dynamic line chart using jfree chart in java
(1 answer)
Closed 8 years ago.
I have a JTable that displays my data. This two JTable contain two Column (col1 for Time and col2 for values of a CO2 sensor).
With a click on Start button, the data will added to the table with a period that I may enter through the console,
After each change in the table of the Koordianten x = time, y = CO2 values, a line chart dynamic it must be shown.
How can I display this Linechart Dynamically.
This is my Code:
public PanelGraphic() throws NotConnectedException {
initComponents();
}
private static JFreeChart createChart(final XYDataset dataset) {
JFreeChart chart = ChartFactory.createXYLineChart(
"KohlendioxdeTest", "Time ", "CO2 (ppm)", dataset,
PlotOrientation.VERTICAL, false, false, false);
return chart;
}
private void display(){
JFreeChart chart = createChart(dataset);
ChartPanel panel = new ChartPanel(chart);
final XYPlot plot = (XYPlot) chart.getPlot();
panel.setPreferredSize(new java.awt.Dimension(515, 265));
PlGraph.setLayout(new java.awt.BorderLayout());
PlGraph.add(panel, BorderLayout.CENTER);
PlGraph.validate();
}
private void jButtonStartActionPerformed(java.awt.event.ActionEvent evt) {
final DefaultTableModel model = (DefaultTableModel) tbCO2Value.getModel();
try {
if (!txtSetPeriode.getText().trim().isEmpty()) {
double peri = Long.parseLong(txtSetPeriode.getText());
co2.setCO2CallbackPeriod((long) peri * 1000);
co2.addCO2PPMListener(new BrickletCO2.PPmListener() {
#Override
public void PPmconverter(int kohlendioxide) {
model.addRow(new Object[]{DisplayTime.getTime(), String.valueOf(kohlendioxide)});
}
});
} else {
lbComment.setText("Bitte geben sie die Periode ein");
}
} catch (NotConnectedException ex) {
Logger.getLogger(PanelGraphic.class.getName()).log(Level.SEVERE, null, ex);
} catch (TimeoutException ex) {
Logger.getLogger(PanelGraphic.class.getName()).log(Level.SEVERE, null, ex);
}
tbCO2Value.getModel().addTableModelListener(new MyTableModelListener(tbCO2Value));
dataset = new XYSeriesCollection();
XYSeries series = new XYSeries("CO2 (ppm)");
int nRow = tbCO2Value.getRowCount();
int nCol = tbCO2Value.getColumnCount();
Object[][] tableData = new Object[nRow][nCol];
for (int i = 0; i < nRow; i++) {
tableData[i][0] = tbCO2Value.getValueAt(i, 0);
tableData[i][1] = tbCO2Value.getValueAt(i, 1);
// for (int i = 0; i < 10; i++) {
series.add((double) tableData[i][0],(double) tableData[i][1] );
}
dataset.addSeries(series);
display();
}
class MyTableModelListener implements TableModelListener {
JTable table;
private MyTableModelListener(JTable table) {
this.table = table;
}
#Override
public void tableChanged(TableModelEvent e) {
int firstRow = e.getFirstRow();
int lastRow = e.getLastRow();
int index = e.getColumn();
switch (e.getType()) {
case TableModelEvent.INSERT:
int nRow = tbCO2Value.getRowCount();
int nCol = tbCO2Value.getColumnCount();
Object[][] tableData = new Object[nRow][nCol];
for (int i = 0; i < nRow; i++) {
tableData[i][0] = tbCO2Value.getValueAt(i, 0);
tableData[i][1] = tbCO2Value.getValueAt(i, 1);
}
break;
}
}
}
}
Just as a JTable listens to its TableModel, an XYPlot listens to its own XYDataset. In each case, simply update the relevant model and the corresponding view will update itself in response. Use a javax.swing.Timer to poll your data source at the rate prescribed by the chosen period. A related example is shown here. In outline, your timer's ActionListener might look like this:
#Override
public void actionPerformed(ActionEvent e) {
// poll the data source
model.add(Row(…);
dataset.add(…);
}
If you anticipate latency in accessing the data source, consider using a SwingWorker, illustrated here and here.
I am trying to run the following code and it throws exception on getSeriesVisible:
chart.addChangeListener(new ChartChangeListener() {
int indexChanged = -1;
#Override
public void chartChanged(ChartChangeEvent event) {
XYPlot ff = chart.getXYPlot();
XYItemRenderer y = ff.getRenderer();
boolean b = y.getSeriesVisible(0);
// chart.getXYPlot().getRenderer().setSeriesVisible(0, b);
}
});
Message: Exception in thread "AWT-EventQueue-0" java.lang.NullPointerException
ANy ideas?
UPDATE: I am adding a couple of series and generating chart as follows:
XYSeriesCollection data = new XYSeriesCollection();
XYSeries series = new XYSeries("Series 1", true);
series.add(1, 2);
series.add(3, 5);
series.add(8, 10);
series.add(11, 3);
series.add(8, 10);
data.addSeries(series);
series = new XYSeries("Series 2");
series.add(5, -2);
series.add(7, 6);
series.add(8, 12);
series.add(11, -2);
series.add(15, 10);
data.addSeries(series);
final JFreeChart chart = ChartFactory.createXYLineChart("Chart", "X", "Y", data, PlotOrientation.VERTICAL, true, true, false);
It must be somewhere else in your code. I see the expected result from this example using the modified addButton() listener below.
addButton.addActionListener(new ActionListener() {
public void actionPerformed(ActionEvent e) {
int n = dataset.getSeriesCount();
dataset.addSeries("Series" + n, createSeries(n));
XYPlot plot = chart.getXYPlot();
XYItemRenderer renderer = plot.getRenderer();
System.out.println(renderer.isSeriesVisible(n));
}
});
Basically, I want a Java GUI with multiple frames, so I'm using JInternalFrame, but when I add my chart (created from JFreeChart) to one of the frames, it gave me an exception.
Exception in thread "AWT-EventQueue-0" java.lang.ClassCastException: javax.swing.plaf.ColorUIResource cannot be cast to java.util.List
at javax.swing.plaf.metal.MetalUtils.drawGradient(Unknown Source)
at javax.swing.plaf.metal.MetalInternalFrameTitlePane.paintComponent(Unknown Source)
at javax.swing.JComponent.paint(Unknown Source)
at javax.swing.JComponent.paintToOffscreen(Unknown Source)
at javax.swing.RepaintManager$PaintManager.paintDoubleBuffered(Unknown Source)
at javax.swing.RepaintManager$PaintManager.paint(Unknown Source)
at javax.swing.RepaintManager.paint(Unknown Source) ....
This is the code :
public class immobile extends JFrame {
JDesktopPane desktop;
public immobile() {
desktop = new JDesktopPane();
desktop.setDesktopManager(new No1DragDesktopManager());
getContentPane().add(desktop);
desktop.add(createInternalFrame(30, 50, Color.WHITE));
desktop.add(createInternalFrame(30, 360, Color.WHITE));
desktop.add(createInternalFrame(630, 50, Color.WHITE));
desktop.add(createInternalFrame(630, 360, Color.WHITE));
}
private JInternalFrame createInternalFrame(
int location1, int location2, Color background) {
JInternalFrame internal =
new JInternalFrame("Frame" + location1, true, true, true, true);
internal.setBackground(background);
internal.setVisible(true);
internal.setResizable(false);
internal.setBounds(location1, location2, 600, 310);
internal.setDefaultCloseOperation(DO_NOTHING_ON_CLOSE);
return internal;
}
public static void main(String args[]) {
immobile frame = new immobile();
frame.setDefaultCloseOperation(EXIT_ON_CLOSE);
frame.setSize(1280, 720);
frame.setLocationRelativeTo(null);
frame.setVisible(true);
frame.setResizable(false);
try {
JInternalFrame[] frames = frame.desktop.getAllFrames();
JInternalFrame f = frames[0];
String url = "http://www.cophieu68.com/export/excel.php?id=ABT";
//create the chart from JFreechart//
JFreeChart chart = garch_project.garch_chart(url);
JPanel chartPanel = new ChartPanel(chart);
f.add(chartPanel);
f.putClientProperty("dragMode", "fixed");
JInternalFrame f3 = frames[2];
f3.putClientProperty("dragMode", "fixed");
JInternalFrame f4 = frames[1];
f4.putClientProperty("dragMode", "fixed");
JInternalFrame f2 = frames[3];
f2.putClientProperty("dragMode", "fixed");
} catch (Exception e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
class No1DragDesktopManager extends DefaultDesktopManager {
public void beginDraggingFrame(JComponent f) {
if (!"fixed".equals(f.getClientProperty("dragMode"))) {
super.beginDraggingFrame(f);
}
}
public void dragFrame(JComponent f, int newX, int newY) {
if (!"fixed".equals(f.getClientProperty("dragMode"))) {
super.dragFrame(f, newX, newY);
}
}
public void endDraggingFrame(JComponent f) {
if (!"fixed".equals(f.getClientProperty("dragMode"))) {
super.endDraggingFrame(f);
}
}
}
}
How could I handle them? Thanks. (I'm using Eclipse, latest version).
Here's a simplified sscce that shows how JFreeChart may be used with JInternalFrame. Note that you should pack() (and optionally size) the internal frame before making it visible. A related example may be found here.
/** #see https://stackoverflow.com/questions/9338466 */
public class InternalFreeChart {
private static final Random rnd = new Random();
public InternalFreeChart() {
JFrame frame = new JFrame();
JDesktopPane desktop = new JDesktopPane();
frame.add(desktop);
for (int i = 1; i < 9; i++) {
desktop.add(createInternalFrame(i));
}
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
frame.pack();
frame.setSize(640, 480);
frame.setLocationRelativeTo(null);
frame.setVisible(true);
}
private JInternalFrame createInternalFrame(int n) {
JInternalFrame jif = new JInternalFrame(
"Frame" + n, true, true, true, true);
jif.setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE);
JFreeChart chart = ChartFactory.createTimeSeriesChart(
"Test", "Time", "Value", createDataset(), true, true, false);
JPanel chartPanel = new ChartPanel(chart);
jif.add(chartPanel);
jif.pack();
jif.setBounds(n * 25, n * 20, 400, 300);
jif.setVisible(true);
return jif;
}
private static XYDataset createDataset() {
TimeSeries series1 = new TimeSeries("Series 1");
TimeSeries series2 = new TimeSeries("Series 2");
SerialDate sd = SerialDate.createInstance(new Date());
for (int i = 1; i < 16; i++) {
Day d = new Day(SerialDate.addDays(i, sd));
series1.add(d, 100 + rnd.nextGaussian() / 2);
series2.add(d, 101 + rnd.nextGaussian() / 2);
}
TimeSeriesCollection dataset = new TimeSeriesCollection();
dataset.addSeries(series1);
dataset.addSeries(series2);
return dataset;
}
public static void main(String args[]) {
EventQueue.invokeLater(new Runnable() {
#Override
public void run() {
InternalFreeChart ifc = new InternalFreeChart();
}
});
}
}
I want to compress and store data of real time line graph I tried but not succeeded
public class DTest extends ApplicationFrame {
javax.swing.Timer _timer;
int nPoints = 200;
float[] history;
/** The most recent value added. */
private float lastValue = (float) 100.0;
DynamicTimeSeriesCollection dataset;
JPanel content;
private final ChartPanel chartPanel;
public DTest(final String title) {
super(title);
history = new float[nPoints];
dataset = new DynamicTimeSeriesCollection(
1, nPoints, new Second()//here speed will set
);
dataset.setTimeBase(new Second(0,0,0,1,1,2000));
dataset.addSeries(new float[]{0.0f}, 0, "S1");
System.out.println("Series count = " + dataset.getSeriesCount());
final JFreeChart chart = createChart(dataset);
chartPanel = new ChartPanel(chart);
content = new JPanel(new FlowLayout());
final JButton btn = new JButton("Stop");
btn.addActionListener(new ActionListener() {
public void actionPerformed(ActionEvent e) {
_timer.stop();
}
});
final JButton btn1 = new JButton("Run");
btn1.addActionListener(new ActionListener() {
public void actionPerformed(ActionEvent e) {
// create new dataset and chart, set the new chart in the chartpanel
//createChart(dataset);
_timer.start();
}
});
JComboBox comb = new JComboBox();
comb.addItem("Select");
comb.addItem("Joy Stick");
content.add(chartPanel);//panel for chart
JPanel btnPanel = new JPanel(new FlowLayout());
btnPanel.add(btn);
btnPanel.add(btn1);
btnPanel.add(comb);
Container pane = getContentPane();
pane.setLayout(new BorderLayout());
pane.add(content, BorderLayout.NORTH);
pane.add(btnPanel, BorderLayout.CENTER);
chartPanel.setPreferredSize(new java.awt.Dimension(500, 270));
//setContentPane(content);
comb.addActionListener(new ActionListener() {
private float[] float_array;
private int itemCount;
public void actionPerformed(ActionEvent e) {
JComboBox jComb = (JComboBox) e.getSource();
if (jComb.getSelectedItem().equals("Joy Stick")) {
System.out.println("Joy Stick is Pressed");
try {
float_array = new float[1];
float_array[0] = 0;
itemCount = 0;
dataset.appendData(float_array);
dataset.addSeries(new float[]{0.0f}, 0, "S1");
_timer = new javax.swing.Timer(1, new ActionListener() { // 500ms
private int resizes;
private int inserted;
public void actionPerformed(ActionEvent e) {
double factor = 0.90 + 0.2 * Math.random();
lastValue = lastValue * (float) factor;
float_array[0] = lastValue;
System.out.println("lastValue is " + lastValue);
inserted++;
if ( inserted % (resizes+1)==0 )
dataset.appendData(float_array, itemCount++, 1);
history[itemCount] = lastValue;
if (itemCount >= nPoints - 1) {
resizes++;
DynamicTimeSeriesCollection newSet = new DynamicTimeSeriesCollection(1, nPoints, new Second());
newSet.setTimeBase(new Second(0,0,0,2,2,2000));
newSet.addSeries(new float[]{0.0f}, 0, "S1");
itemCount /= 2;
for (int i = 1; i < nPoints; i++) {
history[i / 2] = history[i];
float_array[0]=history[i / 2];
newSet.appendData(float_array, i/2, 1);
history[i] = 0;
}
chartPanel.setChart(createChart(newSet));
dataset = newSet;
chartPanel.repaint();
}
}
});
_timer.setRepeats(true);
_timer.start();
} catch (NullPointerException ne) {
System.out.println("NullPointer Exception" + ne.toString());
} catch (Exception ex) {
ex.printStackTrace();
}
} else { ;
}
}
});
}
private JFreeChart createChart(final XYDataset dataset) {
final JFreeChart result = ChartFactory.createTimeSeriesChart(
"Dynamic Graph", "Time", "Value", dataset, true, true,
false);
final XYPlot plot = result.getXYPlot();
ValueAxis axis = plot.getDomainAxis();
//plot.setRangeAxis(WIDTH, axi)
axis.setAutoRange(true);
//axis.setFixedAutoRange(60.0); // 60 seconds
axis = plot.getRangeAxis();
axis.setRange(-100.0, 200.0);
return result;
}
public static void main(final String[] args) {
java.awt.EventQueue.invokeLater(new Runnable() {
public void run() {
try {
final DTest demo = new DTest("Dynamic Graph");
demo.pack();
RefineryUtilities.centerFrameOnScreen(demo);
UIManager.setLookAndFeel(UIManager.getCrossPlatformLookAndFeelClassName());
demo.setVisible(true);
} catch (Exception e) {
}
}
});
}
}
…as the line moves forward, the previous line value should not disappear, but it should begin to compress itself.
The Memory Usage tab of the demo does exactly what you describe.